├── .editorconfig ├── .flake8 ├── .github └── workflows │ ├── ci.yml │ └── code_quality.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.rst ├── CONTRIBUTORS.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_admin_index ├── __init__.py ├── admin.py ├── apps.py ├── conf.py ├── locale │ └── nl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20170802_1754.py │ ├── 0003_auto_20200724_1516.py │ ├── 0004_auto_20230503_0723.py │ ├── 0005_auto_20230503_1910.py │ ├── 0006_auto_20230503_1910.py │ └── __init__.py ├── models.py ├── static │ └── admin │ │ └── css │ │ ├── admin-index.css │ │ └── admin-index.css.map ├── templates │ ├── admin │ │ ├── change_form.html │ │ ├── change_list.html │ │ ├── delete_confirmation.html │ │ ├── delete_selected_confirmation.html │ │ ├── index.html │ │ └── object_history.html │ ├── django_admin_index │ │ └── includes │ │ │ └── app_list.html │ └── registration │ │ ├── password_change_done.html │ │ ├── password_change_form.html │ │ ├── password_reset_complete.html │ │ ├── password_reset_confirm.html │ │ ├── password_reset_done.html │ │ └── password_reset_form.html ├── templatetags │ ├── __init__.py │ └── django_admin_index.py ├── translations.py └── utils.py ├── docs └── _assets │ ├── application_groups.png │ ├── application_groups_thumb.png │ ├── change_user_management_group.png │ ├── change_user_management_group_thumb.png │ ├── dashboard_with_menu.png │ └── dashboard_with_menu_thumb.png ├── manage.py ├── package-lock.json ├── package.json ├── pyproject.toml ├── scss ├── _vars.scss ├── admin-index.scss └── components │ ├── _containers.scss │ ├── _dropdown-menu.scss │ └── _header.scss ├── tests ├── __init__.py ├── proj │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── unit │ ├── __init__.py │ ├── test_admin_index.py │ ├── test_app_group.py │ ├── test_app_link.py │ ├── test_integration.py │ └── test_validators.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.scss] 14 | indent_size = 2 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E501 4 | exclude = 5 | env 6 | .tox 7 | doc 8 | **/migrations/* 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python: ['3.10', '3.11', '3.12'] 18 | django: ['4.2'] 19 | 20 | name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}) 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python }} 27 | 28 | - name: Install dependencies 29 | run: pip install tox tox-gh-actions 30 | 31 | - name: Run tests 32 | run: tox 33 | env: 34 | PYTHON_VERSION: ${{ matrix.python }} 35 | DJANGO: ${{ matrix.django }} 36 | 37 | - name: Publish coverage report 38 | uses: codecov/codecov-action@v4 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | 42 | styles: 43 | name: Build sass into CSS 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-node@v4 49 | with: 50 | node-version-file: '.nvmrc' 51 | - name: Install dependencies 52 | run: npm ci 53 | - name: Compile sass into CSS 54 | run: npm run scss 55 | 56 | publish: 57 | name: Publish package to PyPI 58 | runs-on: ubuntu-latest 59 | needs: tests 60 | environment: release 61 | permissions: 62 | id-token: write 63 | 64 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/setup-python@v5 69 | with: 70 | python-version: '3.10' 71 | 72 | - name: Build wheel 73 | run: | 74 | pip install build --upgrade 75 | python -m build 76 | 77 | - name: Publish a Python distribution to PyPI 78 | uses: pypa/gh-action-pypi-publish@release/v1 79 | -------------------------------------------------------------------------------- /.github/workflows/code_quality.yml: -------------------------------------------------------------------------------- 1 | name: Code quality checks 2 | 3 | # Run this workflow every time a new commit pushed to your repository 4 | on: 5 | push: 6 | branches: 7 | - master 8 | tags: 9 | - '*' 10 | paths: 11 | - '**.py' 12 | pull_request: 13 | paths: 14 | - '**.py' 15 | workflow_dispatch: 16 | 17 | jobs: 18 | linting: 19 | name: Code-quality checks 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | toxenv: 24 | - isort 25 | - black 26 | - flake8 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-python@v5 30 | with: 31 | python-version: '3.10' 32 | - name: Install dependencies 33 | run: pip install tox 34 | - run: tox 35 | env: 36 | TOXENV: ${{ matrix.toxenv }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *$py.class 4 | *~ 5 | .*.sw[pon] 6 | dist/ 7 | *.egg-info 8 | *.egg 9 | *.egg/ 10 | build/ 11 | .build/ 12 | _build/ 13 | pip-log.txt 14 | .directory 15 | erl_crash.dump 16 | *.db 17 | Documentation/ 18 | .tox/ 19 | .ropeproject/ 20 | .project 21 | .pydevproject 22 | .idea/ 23 | .coverage 24 | reports/ 25 | .ve* 26 | cover/ 27 | .vagrant/ 28 | *.sqlite3 29 | .cache/ 30 | .parcel-cache/ 31 | htmlcov/ 32 | coverage.xml 33 | env/ 34 | .vscode/ 35 | .eggs/ 36 | node_modules/ 37 | django_admin_index/static/admin/css/admin-index.js 38 | django_admin_index/static/admin/css/admin-index.js.map 39 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Change history 3 | ============== 4 | 5 | 4.0.0 (2024-12-11) 6 | ================== 7 | 8 | **Changes** 9 | 10 | * Add support for Python 3.12 11 | * Dropped support for Python < 3.10 and Django < 4.2 12 | * Changed App Links from `change` to `view` in admin index 13 | 14 | 3.1.1 (2024-02-29) 15 | ================== 16 | 17 | Bugfix release 18 | 19 | * Fixed N + 1 query regression introduced in 3.1.0 20 | * Fixed typos in README 21 | * Formatted the code with latest black version 22 | * Added missing Dutch translations 23 | 24 | 3.1.0 (2023-07-14) 25 | ================== 26 | 27 | 🌐 Added content translations support 28 | 29 | You can now provide translations for the app group and link names, which are activated 30 | based on the currently active language. You (optionally) provide translations through a 31 | JSON datastructure, keyed by language code. 32 | 33 | * Fixed supported django version badge. 34 | * Added support for content translations. 35 | 36 | 3.0.0 (2023-05-03) 37 | ================== 38 | 39 | Periodic version compatibility release. 40 | 41 | The major version is bumped due to dropping support for Django 2.2 - the library itself 42 | does not have any breaking changes and upgrading from 2.0.x should be smooth. 43 | 44 | **Changes** 45 | 46 | * Fixed some outdated information in the README 47 | * Fixed ``ordered_model.W003`` warning 'OrderedModelBase subclass has a ModelManager 48 | that does not inherit from OrderedModelManager' when using newer ordered-model versions 49 | * Formatted code with latest black and isort versions 50 | * Include admin-index in the password reset pages 51 | * Dropped Django 2.2 support 52 | * Bumped parcel and browserslist versions 53 | * Confirmed support for Python 3.11 54 | * Confirmed support for Django 4.1 and 4.2 55 | * Updated PK fields to use ``BigAutoField`` by default at the library level, following 56 | Django's default. 57 | * Add CI check for sass compilation 58 | * Removed unused makefile 59 | 60 | 2.0.2 (2022-06-16) 61 | ================== 62 | 63 | Fixed packaging mistake in 2.0.1 64 | 65 | 2.0.1 (2022-06-16) 66 | ================== 67 | 68 | Bugfix release for the calendar/timebox modals. 69 | 70 | * Fixed z-index for calendar popup 71 | * Fixed z-index for timebox popup 72 | 73 | 2.0.0 74 | ===== 75 | 76 | *March 15, 2022* 77 | 78 | This release contains breaking changes in the admin markup and stylesheets, hence the 79 | major version bump. 80 | 81 | Breaking changes 82 | ---------------- 83 | 84 | * Dropped support for non-LTS Django versions (1.11, 3.0). Only Django 2.2, 3.2 and 4.0 85 | are officially supported and tested. 86 | * Fixed #69 -- Properly namespaced the ``includes/app_list.html`` template to 87 | ``django_admin_index/includes/app_list.html`` 88 | * Refactored the styling (#71) 89 | 90 | * All django-admin-index classnames now have a ``djai-`` prefix to prevent 91 | collissions (e.g. bootstrap has a ``dropdown-menu`` as well) 92 | * Colour definitions now leverage the Django 3.2 CSS variables, see 93 | ``scss/_vars.scss``. For Django < 3.2 these don't exist, but the fallback values 94 | are defined. This makes it easier to theme django-admin-index in your project by 95 | just overriding the CSS variables instead of the entire relevant selector. 96 | * The markup of the ``django_admin_index/includes/app_list.html`` has been slightly 97 | modified, some class names are moved around. 98 | * The breadcrumbs are no longer sticky/fixed by default, override this in your own 99 | styling if desired. Possibly in the future this may be controllable with a CSS var. 100 | * Made a mimimal working layout on mobile. Tabs wrap on the next line and the menus 101 | are no longer hidden behind the breadcrumbs. 102 | 103 | * Reduced amount of overridden ``django.contrib.admin`` template code - people 104 | overriding the django-admin-index templates may want to revisit these. 105 | 106 | Other improvements 107 | ------------------ 108 | 109 | * Added optional support for Django Debug Toolbar 110 | * Added template overrides for ``registration/password_reset_form.html`` and 111 | ``registration/password_reset_done.html`` 112 | * Updated isort config to be black-compatible 113 | * Updated test project (used for local testing and CI) to Django 2.2 and Django 3.2+ 114 | * Included ``AppConfig.default_auto_field`` for Django 3.2+ 115 | * Fixed stylesheet being loaded in the body instead of the head (#70) 116 | * Restructured packaging setup and repository layout (#73) 117 | 118 | 1.6.0 119 | ===== 120 | 121 | *February 14, 2022* 122 | 123 | * Added support for Django 3.0, 3.2 and 4.0, on supported Python versions (#47) 124 | * Removed merge conflicts from CSS-file (#46) 125 | * Moved to Github actions (#61) 126 | 127 | 1.5.0 128 | ===== 129 | 130 | * Updated package metadata 131 | * Added setting ``ADMIN_INDEX_DISPLAY_DROP_DOWN_MENU_CONDITION_FUNCTION`` to provide 132 | more control on when to display/hide the dropdown menu. The default implementation 133 | is backwards compatible. 134 | 135 | 1.4.0 136 | ===== 137 | 138 | * Fixed #31 -- Prevent excessive queries by changing the context processor to 139 | template tags (thanks @svenvandescheur). 140 | * Fixes #41 -- Added missing migration. 141 | * Fixed #34 -- Don't show item if the menu item URL is undefined. 142 | * Fixed #33 -- Don't show a warning if the Django Admin AppConfig is overriden. 143 | * Fixed #29 -- Added screenshots to README. 144 | 145 | 1.3.1 146 | ===== 147 | 148 | *July 21, 2020* 149 | 150 | * Added active dashboard link tests for different perms. 151 | * Added shadow to dropdown. 152 | * Fixed active menu item for groups without change/read permission. 153 | * Updated npm package requirements (only needed for development). 154 | 155 | 1.3.0 156 | ===== 157 | 158 | *January 20, 2020* 159 | 160 | * Removed Django 1.11 support. 161 | * Removed Python 2.7 support. 162 | * Added Django 3.0 support. 163 | * Added support for Python 3.8 (for eligable Django versions). 164 | * Updated Travis CI config to test all supported Python and Django versions. 165 | * Now depends on ``django-ordered-model`` version 3.0 (or higher) 166 | 167 | 1.2.3 168 | ===== 169 | 170 | *January 16, 2020* 171 | 172 | * Fixed incorrect menu positioning (white line showing). 173 | 174 | 1.2.2 175 | ===== 176 | 177 | *December 5, 2019* 178 | 179 | * Removed accidental print statement. 180 | * Added undocumented change in 1.2.1 changelog regarding the template block 181 | ``breadcrumbs_pre_changelist``. 182 | 183 | 1.2.1 184 | ===== 185 | 186 | *November 29, 2019* 187 | 188 | * Added ``ADMIN_INDEX_SHOW_MENU`` setting to show (default) or hide the extra 189 | menu. 190 | * Added ``ADMIN_INDEX_HIDE_APP_INDEX_PAGES`` setting to show or hide (default) 191 | the application index page link in the breadcrumbs and on the main index 192 | page. 193 | * Added template block ``breadcrumbs_pre_changelist`` which can be overriden 194 | to add a custom breadcrumb between home and the list view. 195 | 196 | 1.2.0 197 | ===== 198 | 199 | *October 18, 2019* 200 | 201 | * Fixed ``AUTO_CREATE_APP_GROUP`` setting to show auto generated groups on the 202 | very first time you render the admin. 203 | * Fixed an issue where staff users didn't see anything if no ``AppGroups`` were 204 | created and showing remaining apps was turned off (thanks @sergeimaertens). 205 | * Fixed admin templates to work with the view permission introduced in 206 | Django 2.1. 207 | * Updated npm package requirements (only needed for development). 208 | 209 | 210 | 1.1.0 211 | ===== 212 | 213 | *October 14, 2019* 214 | 215 | * Added navigation menu based on ``AppGroup`` configuration (thanks @JostCrow). 216 | * Removed Django < 1.11 support. 217 | * Updated test requirements. 218 | 219 | 220 | 1.0.1 221 | ===== 222 | 223 | *March 12, 2018* 224 | 225 | * Fixed a bug with the ``AppGroup`` creation that occurs when the same slug 226 | with and a different ``app_name`` would be created. 227 | * Using the AppConfig verbose name instead of the model name. 228 | 229 | 230 | 1.0 231 | === 232 | 233 | *December 18, 2017* 234 | 235 | * Added Django 2.0 support. 236 | 237 | 238 | 0.9.1 239 | ===== 240 | 241 | *November 3, 2017* 242 | 243 | * Added natural keys for all models. 244 | * Added ``ADMIN_INDEX_AUTO_CREATE_APP_GROUP`` setting to create groups 245 | automatically, if the model was not yet in a group. 246 | 247 | 248 | 0.9.0 249 | ===== 250 | 251 | *July 3, 2017* 252 | 253 | * Initial public release on PyPI. 254 | -------------------------------------------------------------------------------- /CONTRIBUTORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | * Joeri Bekker 5 | 6 | Contributors 7 | ============ 8 | 9 | * Jorik Kraaikamp (@JostCrow) 10 | * Baptiste Darthenay (@batisteo) 11 | * Sven van de Scheur (@svenvandescheur) 12 | * Alex de Landgraaf (@alextreme) 13 | * Steven Bal (@stevenbal) 14 | * Sergei Maertens (@sergei-maertens) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Joeri Bekker 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.typed 3 | include LICENSE 4 | recursive-include django_admin_index *.html 5 | recursive-include django_admin_index *.txt 6 | recursive-include django_admin_index *.po 7 | recursive-include django_admin_index *.mo 8 | recursive-include django_admin_index/static *.css 9 | recursive-include django_admin_index/static *.css.map 10 | recursive-include scss *.scss 11 | global-exclude __pycache__ 12 | global-exclude *.py[co] 13 | global-exclude .*.sw* 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Admin Index for Django 3 | ====================== 4 | 5 | :Version: 4.0.0 6 | :Download: https://pypi.org/project/django-admin-index/ 7 | :Source: https://github.com/maykinmedia/django-admin-index 8 | :Keywords: django, admin, dashboard 9 | 10 | |build-status| |code-quality| |black| |coverage| |license| |python-versions| |django-versions| |pypi-version| 11 | 12 | About 13 | ===== 14 | 15 | This extension enables you to group, order and customize the Django admin 16 | index page without too much hassle or visual changes. 17 | 18 | There are 2 concepts: `Application groups` and `Application links`. You can 19 | create an application group and add any model to it in the Django admin, under 20 | ``Admin index``. Whether the models are shown to the user, depends on the 21 | regular Django permissions and whether it's registered in the admin. 22 | 23 | An application link is simply a URL with a name that you can add to an 24 | application group. It shows as a regular Django model. 25 | 26 | One final change in the Django admin is the removal of the App lists, that 27 | link to a list of models within an App. This concept became obsolete. 28 | 29 | |screenshot-1| |screenshot-2| |screenshot-3| 30 | 31 | Installation 32 | ============ 33 | 34 | You can install django_admin_index either via the Python Package Index (PyPI) 35 | or from source. 36 | 37 | To install using ``pip``: 38 | 39 | .. code-block:: console 40 | 41 | $ pip install -U django-admin-index 42 | 43 | Usage 44 | ===== 45 | 46 | To use this with your project you need to follow these steps: 47 | 48 | #. Add ``django_admin_index`` and ``ordered_model`` to ``INSTALLED_APPS`` in 49 | your Django project's ``settings.py``. Make sure that 50 | ``django_admin_index`` comes before ``django.contrib.admin``: 51 | 52 | .. code-block:: python 53 | 54 | INSTALLED_APPS = ( 55 | "django_admin_index", 56 | "ordered_model", 57 | ..., 58 | "django.contrib.admin", 59 | ) 60 | 61 | Note that there is no dash in the module name, only underscores. 62 | 63 | #. Create the database tables by performing a database migration: 64 | 65 | .. code-block:: console 66 | 67 | $ python manage.py migrate admin_index 68 | 69 | #. Go to the Django admin of your site and look for the "Application groups" 70 | section. 71 | 72 | Configuration 73 | ============= 74 | 75 | There are optional settings you can add to your ``settings.py``: 76 | 77 | - ``ADMIN_INDEX_SHOW_REMAINING_APPS`` (defaults to ``False``) 78 | 79 | Show all models that are not added to an `Application group` in a group 80 | called "Miscellaneous" for **staff** users. 81 | 82 | NOTE: If no `Application groups` are defined, it will show all models 83 | regardless of this setting. 84 | 85 | - ``ADMIN_INDEX_SHOW_REMAINING_APPS_TO_SUPERUSERS`` (defaults to ``True``) 86 | 87 | Show all models that are not added a to an `Application group` in a group 88 | called "Miscellaneous" for **super users** users. 89 | 90 | NOTE: If no `Application groups` are defined, it will show all models 91 | regardless of this setting. 92 | 93 | - ``ADMIN_INDEX_AUTO_CREATE_APP_GROUP`` (defaults to ``False``) 94 | 95 | Automaticly creates an `Application group`, based on the `app_label`, for 96 | all the models that would be in the "Miscellaneous" group. If ``True``, your 97 | Django admin will initially look as it normally would. It will not update 98 | existing `Application groups`. 99 | 100 | - ``ADMIN_INDEX_SHOW_MENU`` (defaults to: ``True``) 101 | 102 | Show the admin index as a menu above the breadcrumbs. Submenu's are filled 103 | with the registered models. 104 | 105 | * ``ADMIN_INDEX_HIDE_APP_INDEX_PAGES`` (defaults to: ``True``) 106 | 107 | Removes the links to the app index pages from the main index and the 108 | breadcrumbs. 109 | 110 | * ``ADMIN_INDEX_DISPLAY_DROP_DOWN_MENU_CONDITION_FUNCTION`` (defaults to 111 | ``django_admin_index.utils.should_display_dropdown_menu``) 112 | 113 | A Python dotted path that can be imported to check when the dropdown menu should be 114 | displayed in the admin. The default implementation displays this menu if the user is 115 | a staff user and ``ADMIN_INDEX_SHOW_MENU`` is enabled. 116 | 117 | Extra 118 | ===== 119 | 120 | Theming 121 | ------- 122 | 123 | By default, django-admin-index tabs/dropdowns are styled in the Django admin theme 124 | colours. On Django 3.2+ these are controlled through CSS variables in the 125 | ``static/admin/css/base.css`` stylesheet. These CSS variables are used as defaults for 126 | django-admin-index' own CSS variables. 127 | 128 | See ``scss/_vars.scss`` for all the available CSS variables you can use to customize 129 | the color palette. A simple example: 130 | 131 | .. code-block:: css 132 | 133 | :root { 134 | --djai-tab-bg: #ff0080; 135 | --djai-tab-bg--hover: #a91b60; 136 | } 137 | 138 | Any rules not supported by CSS vars can be overridden with regular CSS. All elements 139 | have CSS class names following the BEM methodology, such as 140 | ``.djai-dropdown-menu__item`` and 141 | ``.djai-dropdown-menu__item.djai-dropdown-menu__item--active``. 142 | 143 | 144 | Sticky header 145 | ------------- 146 | 147 | The header (typically "Django administration") including the menu (added by this 148 | library) become sticky (ie. they stay visible when you scroll down on large pages). If 149 | you don't want this, you can add some CSS lines, like: 150 | 151 | .. code-block:: css 152 | 153 | #header { position: initial; } 154 | .djai-dropdown-menu { position: initial; } 155 | 156 | 157 | Breadcrumbs 158 | ----------- 159 | 160 | You can also squeeze additional content in the breadcrumbs, just after 161 | ``Home``. Simply overwrite the block ``breadcrumbs_pre_changelist`` in the 162 | admin templates you desire (``change_list.html``, ``change_form.html``, etc.) 163 | 164 | .. code-block:: django 165 | 166 | {% block breadcrumbs_pre_changelist %} 167 | › Meaningful breadcrumb element 168 | {% endblock %} 169 | 170 | 171 | Contributors 172 | ============ 173 | 174 | Contributors and maintainers can install the project locally with all test dependencies 175 | in a virtualenv: 176 | 177 | .. code-block:: bash 178 | 179 | (env) $ pip install -e .[tests,pep8,coverage,release] 180 | 181 | Running the test suite 182 | ---------------------- 183 | 184 | To run the tests for a single environment (currently installed in your virtualenv), use 185 | ``pytest``: 186 | 187 | .. code-block:: bash 188 | 189 | (env) $ pytest 190 | 191 | To run the complete build matrix, use ``tox``: 192 | 193 | .. code-block:: bash 194 | 195 | (env) $ tox 196 | 197 | Developing the frontend 198 | ----------------------- 199 | 200 | To develop the stylesheets, you can use the included test project: 201 | 202 | .. code-block:: bash 203 | 204 | (env) $ python manage.py runserver 205 | 206 | You also want to install the frontend tooling and run the SCSS compilation to CSS in 207 | watch mode: 208 | 209 | .. code-block:: bash 210 | 211 | npm install # one time to get the dependencies installed 212 | npm run watch 213 | 214 | Once the result is satisfactory, you can make a production build of the stylesheets: 215 | 216 | .. code-block:: bash 217 | 218 | npm run scss 219 | 220 | Then, commit the changes and make a pull request. 221 | 222 | 223 | .. |build-status| image:: https://github.com/maykinmedia/django-admin-index/actions/workflows/ci.yml/badge.svg 224 | :alt: Build status 225 | :target: https://github.com/maykinmedia/django-admin-index/actions/workflows/ci.yml 226 | 227 | .. |code-quality| image:: https://github.com/maykinmedia/django-admin-index/workflows/Code%20quality%20checks/badge.svg 228 | :alt: Code quality checks 229 | :target: https://github.com/maykinmedia/django-admin-index/actions?query=workflow%3A%22Code+quality+checks%22 230 | 231 | .. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg 232 | :target: https://github.com/psf/black 233 | 234 | .. |coverage| image:: https://codecov.io/github/maykinmedia/django-admin-index/coverage.svg?branch=master 235 | :target: https://codecov.io/github/maykinmedia/django-admin-index?branch=master 236 | 237 | .. |license| image:: https://img.shields.io/pypi/l/django-admin-index.svg 238 | :alt: BSD License 239 | :target: https://opensource.org/licenses/BSD-3-Clause 240 | 241 | .. |python-versions| image:: https://img.shields.io/pypi/pyversions/django-admin-index.svg 242 | :alt: Supported Python versions 243 | :target: http://pypi.python.org/pypi/django-admin-index/ 244 | 245 | .. |django-versions| image:: https://img.shields.io/pypi/djversions/django-admin-index.svg 246 | :alt: Supported Django versions 247 | :target: http://pypi.python.org/pypi/django-admin-index/ 248 | 249 | .. |pypi-version| image:: https://img.shields.io/pypi/v/django-admin-index.svg 250 | :target: https://pypi.org/project/django-admin-index/ 251 | 252 | .. |screenshot-1| image:: https://github.com/maykinmedia/django-admin-index/raw/master/docs/_assets/dashboard_with_menu_thumb.png 253 | :alt: Ordered dashboard with dropdown menu. 254 | :target: https://github.com/maykinmedia/django-admin-index/raw/master/docs/_assets/dashboard_with_menu.png 255 | 256 | .. |screenshot-2| image:: https://github.com/maykinmedia/django-admin-index/raw/master/docs/_assets/application_groups_thumb.png 257 | :alt: Manage Application groups. 258 | :target: https://github.com/maykinmedia/django-admin-index/raw/master/docs/_assets/application_groups.png 259 | 260 | .. |screenshot-3| image:: https://github.com/maykinmedia/django-admin-index/raw/master/docs/_assets/change_user_management_group_thumb.png 261 | :alt: Configure application groups and add Application links. 262 | :target: https://github.com/maykinmedia/django-admin-index/raw/master/docs/_assets/change_user_management_group.png 263 | -------------------------------------------------------------------------------- /django_admin_index/__init__.py: -------------------------------------------------------------------------------- 1 | # :copyright: (c) 2017, Maykin Media BV. 2 | # All rights reserved. 3 | # :license: BSD (3 Clause), see LICENSE for more details. 4 | from importlib.metadata import version 5 | 6 | __version__ = version("django-admin-index") 7 | __author__ = "Joeri Bekker" 8 | -------------------------------------------------------------------------------- /django_admin_index/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from ordered_model.admin import OrderedModelAdmin 4 | 5 | from .models import AppGroup, AppLink 6 | 7 | 8 | class AppLinkInline(admin.TabularInline): 9 | model = AppLink 10 | fields = ( 11 | "name", 12 | "translations", 13 | "link", 14 | ) 15 | fk_name = "app_group" 16 | extra = 0 17 | 18 | 19 | @admin.register(AppGroup) 20 | class AppGroupAdmin(OrderedModelAdmin): 21 | list_display = ( 22 | "name", 23 | "move_up_down_links", 24 | ) 25 | fields = ( 26 | "name", 27 | "translations", 28 | "slug", 29 | "models", 30 | ) 31 | prepopulated_fields = {"slug": ("name",)} 32 | filter_horizontal = ("models",) 33 | inlines = (AppLinkInline,) 34 | -------------------------------------------------------------------------------- /django_admin_index/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.core.checks import Error, Tags, Warning, register 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | __all__ = ["AdminIndexConfig"] 6 | 7 | 8 | class AdminIndexConfig(AppConfig): 9 | """Default configuration for the django_admin_index app.""" 10 | 11 | name = "django_admin_index" 12 | label = "admin_index" 13 | verbose_name = _("Admin Index") 14 | default_auto_field = "django.db.models.BigAutoField" 15 | 16 | def ready(self): 17 | register(check_admin_index_app, Tags.compatibility) 18 | register(check_admin_index_context_processor, Tags.compatibility) 19 | register(check_request_context_processor, Tags.compatibility) 20 | 21 | 22 | def check_admin_index_app(app_configs, **kwargs): 23 | issues = [] 24 | app_config_names = [app_config.name for app_config in apps.get_app_configs()] 25 | 26 | try: 27 | if app_config_names.index(AdminIndexConfig.name) > app_config_names.index( 28 | "django.contrib.admin" 29 | ): 30 | issues.append( 31 | Warning( 32 | "You should put '{}' before 'django.contrib.admin' in your INSTALLED_APPS.".format( 33 | AdminIndexConfig.name 34 | ) 35 | ) 36 | ) 37 | except ValueError: 38 | issues.append( 39 | Warning("You are missing 'django.contrib.admin' in your INSTALLED_APPS.") 40 | ) 41 | 42 | return issues 43 | 44 | 45 | def check_admin_index_context_processor(app_configs, **kwargs): 46 | from django.conf import settings 47 | 48 | issues = [] 49 | context_procesor = "{}.context_processors.dashboard".format(AdminIndexConfig.name) 50 | 51 | for engine in settings.TEMPLATES: 52 | if "OPTIONS" in engine and "context_processors" in engine["OPTIONS"]: 53 | if context_procesor in engine["OPTIONS"]["context_processors"]: 54 | issues.append( 55 | Error( 56 | "You should remove '{}' from your TEMPLATES.OPTIONS.context_processors.".format( 57 | context_procesor 58 | ) 59 | ) 60 | ) 61 | break 62 | 63 | return issues 64 | 65 | 66 | def check_request_context_processor(app_configs, **kwargs): 67 | from django.conf import settings 68 | 69 | issues = [] 70 | found = False 71 | context_procesor = "django.template.context_processors.request" 72 | 73 | for engine in settings.TEMPLATES: 74 | if "OPTIONS" in engine and "context_processors" in engine["OPTIONS"]: 75 | if context_procesor in engine["OPTIONS"]["context_processors"]: 76 | found = True 77 | break 78 | 79 | if not found: 80 | issues.append( 81 | Warning( 82 | "You are missing '{}' in your TEMPLATES.OPTIONS.context_processors.".format( 83 | context_procesor 84 | ) 85 | ) 86 | ) 87 | 88 | return issues 89 | -------------------------------------------------------------------------------- /django_admin_index/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | 3 | 4 | class Settings: 5 | @property 6 | def SHOW_REMAINING_APPS(self): 7 | return getattr(django_settings, "ADMIN_INDEX_SHOW_REMAINING_APPS", False) 8 | 9 | @property 10 | def SHOW_REMAINING_APPS_TO_SUPERUSERS(self): 11 | return getattr( 12 | django_settings, "ADMIN_INDEX_SHOW_REMAINING_APPS_TO_SUPERUSERS", True 13 | ) 14 | 15 | def show_remaining_apps(self, is_superuser): 16 | if self.SHOW_REMAINING_APPS: 17 | return True 18 | 19 | return settings.SHOW_REMAINING_APPS_TO_SUPERUSERS and is_superuser 20 | 21 | @property 22 | def AUTO_CREATE_APP_GROUP(self): 23 | return getattr(django_settings, "ADMIN_INDEX_AUTO_CREATE_APP_GROUP", False) 24 | 25 | @property 26 | def SHOW_MENU(self): 27 | return getattr(django_settings, "ADMIN_INDEX_SHOW_MENU", True) 28 | 29 | @property 30 | def HIDE_APP_INDEX_PAGES(self): 31 | return getattr(django_settings, "ADMIN_INDEX_HIDE_APP_INDEX_PAGES", True) 32 | 33 | def as_dict(self): 34 | """ 35 | Returns a `dict` with all settings. 36 | 37 | :return: A `dict` with settings. 38 | """ 39 | return {k: getattr(self, k) for k in dir(self) if k.upper() == k} 40 | 41 | @property 42 | def DISPLAY_DROP_DOWN_MENU_CONDITION_FUNCTION(self): 43 | return getattr( 44 | django_settings, 45 | "ADMIN_INDEX_DISPLAY_DROP_DOWN_MENU_CONDITION_FUNCTION", 46 | "django_admin_index.utils.should_display_dropdown_menu", 47 | ) 48 | 49 | 50 | settings = Settings() 51 | -------------------------------------------------------------------------------- /django_admin_index/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/django_admin_index/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_admin_index/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 4.0.0\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-02-29 22:25+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: apps.py:13 22 | msgid "Admin Index" 23 | msgstr "Admin Index" 24 | 25 | #: models.py:127 26 | msgid "Miscellaneous" 27 | msgstr "Overige" 28 | 29 | #: models.py:160 30 | msgid "name" 31 | msgstr "naam" 32 | 33 | #: models.py:161 34 | msgid "slug" 35 | msgstr "slug" 36 | 37 | #: models.py:167 38 | msgid "application group" 39 | msgstr "applicatiegroep" 40 | 41 | #: models.py:168 42 | msgid "application groups" 43 | msgstr "applicatiegroepen" 44 | 45 | #: models.py:185 46 | msgid "application link" 47 | msgstr "applicatielink" 48 | 49 | #: models.py:186 50 | msgid "application links" 51 | msgstr "applicatielinks" 52 | 53 | #: templates/admin/change_form.html:13 templates/admin/change_list.html:13 54 | #: templates/admin/delete_confirmation.html:19 55 | #: templates/admin/delete_selected_confirmation.html:19 56 | #: templates/admin/object_history.html:12 57 | msgid "Home" 58 | msgstr "" 59 | 60 | #: templates/admin/change_form.html:21 61 | #, python-format 62 | msgid "Add %(name)s" 63 | msgstr "" 64 | 65 | #: templates/admin/delete_confirmation.html:27 66 | msgid "Delete" 67 | msgstr "" 68 | 69 | #: templates/admin/delete_selected_confirmation.html:26 70 | msgid "Delete multiple objects" 71 | msgstr "" 72 | 73 | #: templates/admin/index.html:24 74 | #, python-format 75 | msgid "Models in the %(name)s application" 76 | msgstr "" 77 | 78 | #: templates/admin/index.html:36 79 | msgid "Add" 80 | msgstr "" 81 | 82 | #: templates/admin/index.html:43 83 | msgid "View" 84 | msgstr "" 85 | 86 | #: templates/admin/index.html:45 87 | msgid "Change" 88 | msgstr "" 89 | 90 | #: templates/admin/index.html:56 91 | msgid "You don't have permission to view or edit anything." 92 | msgstr "" 93 | 94 | #: templates/admin/object_history.html:20 95 | msgid "History" 96 | msgstr "" 97 | 98 | #: templates/django_admin_index/includes/app_list.html:11 99 | msgid "Dashboard" 100 | msgstr "Dashboard" 101 | 102 | #: translations.py:10 103 | msgid "The format of translations needs to be a JSON-object." 104 | msgstr "De vertalingen moeten een JSON-object zijn." 105 | 106 | #: translations.py:17 107 | #, python-brace-format 108 | msgid "The language code '{language_code}' is not enabled." 109 | msgstr "De taalcode '{language_code}' is niet actief." 110 | 111 | #: translations.py:25 112 | #, python-brace-format 113 | msgid "The translation for language '{language_code}' is not a string." 114 | msgstr "De vertaling voor de taal '{language_code}' moet een string zijn." 115 | 116 | #: translations.py:32 117 | msgid "translations" 118 | msgstr "vertalingen" 119 | 120 | #: translations.py:35 121 | msgid "" 122 | "A JSON-object that uses the Django language code as key and the localized " 123 | "name as value. If no translation can be found for the active language, the " 124 | "name is used as fallback. Example: {\"en\": \"File\", \"nl\": \"Bestand\"}" 125 | msgstr "" 126 | "Een JSON-object waarbij de key een Django taalcode is en de vertaalde naam " 127 | "de waarde. Indien er geen vertaling bestaat voor de actieve taal, dan wordt de " 128 | "onvertaalde naam gebruikt. Bijvoorbeeld: {\"en\": \"File\", \"nl\": \"Bestand\"}" 129 | -------------------------------------------------------------------------------- /django_admin_index/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.2 on 2017-07-02 08:40 3 | import django.contrib.contenttypes.models 4 | import django.db.models.deletion 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | ("contenttypes", "0002_remove_content_type_name"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="AppGroup", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("order", models.PositiveIntegerField(db_index=True, editable=False)), 29 | ("name", models.CharField(max_length=200, verbose_name="name")), 30 | ("slug", models.SlugField(unique=True, verbose_name="slug")), 31 | ], 32 | options={ 33 | "verbose_name_plural": "application groups", 34 | "verbose_name": "application group", 35 | "abstract": False, 36 | "ordering": ("order",), 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name="AppLink", 41 | fields=[ 42 | ( 43 | "id", 44 | models.AutoField( 45 | auto_created=True, 46 | primary_key=True, 47 | serialize=False, 48 | verbose_name="ID", 49 | ), 50 | ), 51 | ("order", models.PositiveIntegerField(db_index=True, editable=False)), 52 | ("name", models.CharField(max_length=200)), 53 | ("link", models.CharField(max_length=200)), 54 | ( 55 | "app_group", 56 | models.ForeignKey( 57 | on_delete=django.db.models.deletion.CASCADE, 58 | to="admin_index.AppGroup", 59 | ), 60 | ), 61 | ], 62 | options={ 63 | "verbose_name_plural": "application links", 64 | "verbose_name": "application link", 65 | "abstract": False, 66 | "ordering": ("order",), 67 | }, 68 | ), 69 | migrations.CreateModel( 70 | name="ContentTypeProxy", 71 | fields=[], 72 | options={ 73 | "proxy": True, 74 | "ordering": ("app_label", "model"), 75 | }, 76 | bases=("contenttypes.contenttype",), 77 | managers=[ 78 | ("objects", django.contrib.contenttypes.models.ContentTypeManager()), 79 | ], 80 | ), 81 | migrations.AddField( 82 | model_name="appgroup", 83 | name="models", 84 | field=models.ManyToManyField(blank=True, to="admin_index.ContentTypeProxy"), 85 | ), 86 | ] 87 | -------------------------------------------------------------------------------- /django_admin_index/migrations/0002_auto_20170802_1754.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("admin_index", "0001_initial"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterUniqueTogether( 12 | name="applink", 13 | unique_together=set([("app_group", "link")]), 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /django_admin_index/migrations/0003_auto_20200724_1516.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-24 15:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("admin_index", "0002_auto_20170802_1754"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="appgroup", 14 | name="order", 15 | field=models.PositiveIntegerField( 16 | db_index=True, editable=False, verbose_name="order" 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="applink", 21 | name="order", 22 | field=models.PositiveIntegerField( 23 | db_index=True, editable=False, verbose_name="order" 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /django_admin_index/migrations/0004_auto_20230503_0723.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-05-03 07:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("admin_index", "0003_auto_20200724_1516"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="appgroup", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="applink", 21 | name="id", 22 | field=models.BigAutoField( 23 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /django_admin_index/migrations/0005_auto_20230503_1910.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-05-03 17:10 2 | 3 | from django.db import migrations, models 4 | 5 | import django_admin_index.translations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("admin_index", "0004_auto_20230503_0723"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="appgroup", 16 | name="translations", 17 | field=models.JSONField( 18 | default=dict, 19 | help_text='A JSON-object that uses the Django language code as key and the localized name as value. If no translation can be found for the active language, the name is used as fallback. Example: {"en": "File", "nl": "Bestand"}', 20 | validators=[ 21 | django_admin_index.translations.validate_translation_json_format 22 | ], 23 | verbose_name="translations", 24 | ), 25 | ), 26 | migrations.AddField( 27 | model_name="applink", 28 | name="translations", 29 | field=models.JSONField( 30 | default=dict, 31 | help_text='A JSON-object that uses the Django language code as key and the localized name as value. If no translation can be found for the active language, the name is used as fallback. Example: {"en": "File", "nl": "Bestand"}', 32 | validators=[ 33 | django_admin_index.translations.validate_translation_json_format 34 | ], 35 | verbose_name="translations", 36 | ), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /django_admin_index/migrations/0006_auto_20230503_1910.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-05-03 17:10 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | def copy_fallback_name_to_translations(apps, _): 8 | AppGroup = apps.get_model("admin_index", "AppGroup") 9 | AppLink = apps.get_model("admin_index", "AppLink") 10 | 11 | if not settings.LANGUAGE_CODE: 12 | return 13 | 14 | for app_group in AppGroup.objects.all(): 15 | app_group.translations = {settings.LANGUAGE_CODE: app_group.name} 16 | app_group.save() 17 | 18 | for app_link in AppLink.objects.all(): 19 | app_link.translations = {settings.LANGUAGE_CODE: app_link.name} 20 | app_link.save() 21 | 22 | 23 | class Migration(migrations.Migration): 24 | dependencies = [ 25 | ("admin_index", "0005_auto_20230503_1910"), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython( 30 | copy_fallback_name_to_translations, migrations.RunPython.noop 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /django_admin_index/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/django_admin_index/migrations/__init__.py -------------------------------------------------------------------------------- /django_admin_index/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import site 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.db.models import F, Prefetch 5 | from django.urls import reverse 6 | from django.utils.text import capfirst 7 | from django.utils.translation import get_language, gettext_lazy as _ 8 | 9 | from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet 10 | 11 | from .conf import settings 12 | from .translations import TranslationsMixin 13 | 14 | 15 | class AppGroupQuerySet(OrderedModelQuerySet): 16 | def get_by_natural_key(self, slug): 17 | return self.get(slug=slug) 18 | 19 | def as_list(self, request, include_remaining=True): 20 | # Convert to convenient dict 21 | model_dicts = {} 22 | 23 | original_app_list = site.get_app_list(request) 24 | 25 | for app in original_app_list: 26 | for model in app["models"]: 27 | key = "{}.{}".format( 28 | app["app_label"], model["object_name"].lower() 29 | ) # noqa 30 | model_dict = model.copy() 31 | 32 | # If the user lacks create/read/update permissions, these 33 | # variables are None in the model_dict 34 | if model_dict.get("admin_url"): 35 | active = request.path.startswith(model_dict["admin_url"]) 36 | elif model_dict.get("add_url"): 37 | active = request.path.startswith(model_dict["add_url"]) 38 | else: 39 | active = False 40 | 41 | model_dict.update( 42 | { 43 | "app_label": app["app_label"], 44 | "app_name": app["name"], 45 | "app_url": app["app_url"], 46 | "has_module_perms": app["has_module_perms"], 47 | "active": active, 48 | } 49 | ) 50 | model_dicts[key] = model_dict 51 | 52 | added = [] 53 | 54 | language_code = get_language() 55 | 56 | # Create new list based on our groups, using the model_dicts constructed above. # noqa 57 | result = [] 58 | app_list = self.annotate( 59 | localized_name=F(f"translations__{language_code}"), 60 | ).prefetch_related( 61 | "models", 62 | Prefetch( 63 | "applink_set", 64 | queryset=AppLink.objects.annotate( 65 | localized_name=F(f"translations__{language_code}"), 66 | ), 67 | ), 68 | ) 69 | active_app = request.path == reverse("admin:index") 70 | for app in app_list: 71 | models = [] 72 | active = False 73 | for model in app.models.all(): 74 | key = "{}.{}".format(model.app_label, model.model) 75 | o = model_dicts.get(key) 76 | if o: 77 | models.append(o) 78 | added.append(key) 79 | if o["active"]: 80 | active = True 81 | 82 | for app_link in app.applink_set.all(): 83 | models.append( 84 | { 85 | "name": app_link.localized_name or app_link.name, 86 | "app_label": app.slug, 87 | "admin_url": app_link.link, 88 | "active": request.path.startswith(app_link.link), 89 | "view_only": True, 90 | } 91 | ) 92 | active = request.path.startswith(app_link.link) 93 | 94 | if models: 95 | result.append( 96 | { 97 | "name": app.localized_name or app.name, 98 | "app_label": app.slug, 99 | "models": sorted(models, key=lambda m: m["name"]), 100 | "active": active, 101 | } 102 | ) 103 | if active: 104 | active_app = True 105 | 106 | other = [model_dicts[k] for k in model_dicts if k not in added] 107 | 108 | if settings.AUTO_CREATE_APP_GROUP: 109 | new_apps = False 110 | for model in other: 111 | app_group, created = AppGroup.objects.get_or_create( 112 | slug=model["app_label"], defaults={"name": model["app_name"]} 113 | ) 114 | if created: 115 | new_apps = True 116 | contenttype = ContentTypeProxy.objects.get( 117 | app_label=model["app_label"], model=model["object_name"].lower() 118 | ) 119 | app_group.models.add(contenttype) 120 | 121 | # If apps are created, rerender the list. 122 | if new_apps: 123 | return self.as_list(request, include_remaining) 124 | 125 | elif other and include_remaining: 126 | result.append( 127 | { 128 | "name": _("Miscellaneous"), 129 | "app_label": "misc", 130 | "models": sorted(other, key=lambda m: m["name"]), 131 | "active": not active_app, 132 | } 133 | ) 134 | 135 | return result 136 | 137 | 138 | class AppLinkQuerySet(OrderedModelQuerySet): 139 | def get_by_natural_key(self, app_group, link): 140 | return self.get(app_group=app_group, link=link) 141 | 142 | 143 | class AppGroupManager(OrderedModelManager): 144 | pass 145 | 146 | 147 | class AppLinkManager(OrderedModelManager): 148 | pass 149 | 150 | 151 | class ContentTypeProxy(ContentType): 152 | class Meta: 153 | proxy = True 154 | ordering = ("app_label", "model") 155 | 156 | def __str__(self): 157 | return "{}.{}".format(self.app_label, capfirst(self.model)) 158 | 159 | 160 | class AppGroup(TranslationsMixin, OrderedModel): 161 | name = models.CharField(_("name"), max_length=200) 162 | slug = models.SlugField(_("slug"), unique=True) 163 | models = models.ManyToManyField(ContentTypeProxy, blank=True) 164 | 165 | objects = AppGroupManager.from_queryset(AppGroupQuerySet)() 166 | 167 | class Meta(OrderedModel.Meta): 168 | verbose_name = _("application group") 169 | verbose_name_plural = _("application groups") 170 | 171 | def natural_key(self): 172 | return (self.slug,) 173 | 174 | def __str__(self): 175 | return self.name 176 | 177 | 178 | class AppLink(TranslationsMixin, OrderedModel): 179 | app_group = models.ForeignKey(AppGroup, on_delete=models.CASCADE) 180 | name = models.CharField(max_length=200) 181 | link = models.CharField(max_length=200) 182 | 183 | objects = AppLinkManager.from_queryset(AppLinkQuerySet)() 184 | 185 | class Meta(OrderedModel.Meta): 186 | verbose_name = _("application link") 187 | verbose_name_plural = _("application links") 188 | unique_together = (("app_group", "link"),) 189 | 190 | def natural_key(self): 191 | return (self.app_group, self.link) 192 | 193 | def __str__(self): 194 | return self.name 195 | -------------------------------------------------------------------------------- /django_admin_index/static/admin/css/admin-index.css: -------------------------------------------------------------------------------- 1 | :root{--djai-tab-bg:#4383a3;--djai-tab-bg--active:var(--primary);--djai-tab-bg--hover:var(--primary);--djai-tab-fg:var(--primary-fg);--djai-tab-fg--active:var(--primary-fg);--djai-tab-fg--hover:var(--primary-fg);--djai-dropdown-bg:var(--secondary);--djai-dropdown-bg--active:var(--primary);--djai-dropdown-bg--hover:var(--primary);--djai-dropdown-fg:var(--header-link-color);--djai-tablist-spacing-top:10px;--djai-nav-sidebar-offset:84px}#container{min-height:100%;height:auto}#main{z-index:1}#header{z-index:2}.calendarbox,.clockbox{z-index:1}#nav-sidebar{top:var(--djai-nav-sidebar-offset)}#header{top:var(--djai-header-offset,0);position:sticky;overflow:visible}:root{--djai-tab-bg:#4383a3;--djai-tab-bg--active:var(--primary);--djai-tab-bg--hover:var(--primary);--djai-tab-fg:var(--primary-fg);--djai-tab-fg--active:var(--primary-fg);--djai-tab-fg--hover:var(--primary-fg);--djai-dropdown-bg:var(--secondary);--djai-dropdown-bg--active:var(--primary);--djai-dropdown-bg--hover:var(--primary);--djai-dropdown-fg:var(--header-link-color);--djai-tablist-spacing-top:10px;--djai-nav-sidebar-offset:84px}.djai-dropdown-menu{margin-top:var(--djai-tablist-spacing-top);flex-basis:100%;display:block}#header{flex-wrap:wrap;padding-bottom:0}.djai-dropdown-menu .djai-dropdown-menu__item{background-color:var(--djai-tab-bg);cursor:pointer;padding:10px;display:inline-block;position:relative}.djai-dropdown-menu .djai-dropdown-menu__item,.djai-dropdown-menu .djai-dropdown-menu__item:link,.djai-dropdown-menu .djai-dropdown-menu__item:visited{color:var(--djai-tab-fg)}.djai-dropdown-menu .djai-dropdown-menu__item--active{background-color:var(--djai-tab-bg--active)}.djai-dropdown-menu .djai-dropdown-menu__item--active,.djai-dropdown-menu .djai-dropdown-menu__item--active:link,.djai-dropdown-menu .djai-dropdown-menu__item--active:visited{color:var(--djai-tab-fg--active);text-decoration:none}.djai-dropdown-menu .djai-dropdown-menu__item:hover,.djai-dropdown-menu .djai-dropdown-menu__item:focus{background-color:var(--djai-tab-bg--hover)}.djai-dropdown-menu .djai-dropdown-menu__item:hover,.djai-dropdown-menu .djai-dropdown-menu__item:hover:link,.djai-dropdown-menu .djai-dropdown-menu__item:hover:visited,.djai-dropdown-menu .djai-dropdown-menu__item:focus,.djai-dropdown-menu .djai-dropdown-menu__item:focus:link,.djai-dropdown-menu .djai-dropdown-menu__item:focus:visited{color:var(--djai-tab-fg--hover);text-decoration:none!important}.djai-dropdown-menu .djai-dropdown-menu__item:hover .djai-dropdown-menu__drop,.djai-dropdown-menu .djai-dropdown-menu__item:focus .djai-dropdown-menu__drop{display:block}.djai-dropdown-menu .djai-dropdown-menu__drop{min-width:150px;z-index:1;max-height:75vh;background-color:var(--djai-dropdown-bg);display:none;position:absolute;top:100%;left:0;overflow-y:auto;box-shadow:0 4px 8px #0003,0 6px 20px #0003}.djai-dropdown-menu .djai-dropdown-menu__drop-item{background-color:#0000}.djai-dropdown-menu .djai-dropdown-menu__drop-item--active{background-color:var(--djai-dropdown-bg--active)}.djai-dropdown-menu .djai-dropdown-menu__drop-item:hover,.djai-dropdown-menu .djai-dropdown-menu__drop-item:focus{background-color:var(--djai-dropdown-bg--hover)}#header .djai-dropdown-menu .djai-dropdown-menu__drop-item a,#header .djai-dropdown-menu .djai-dropdown-menu__drop-item a:link,#header .djai-dropdown-menu .djai-dropdown-menu__drop-item a:visited{color:var(--djai-dropdown-fg)}#header .djai-dropdown-menu .djai-dropdown-menu__drop-item a:hover{text-decoration:none}.djai-dropdown-menu .djai-dropdown-menu__link{padding:10px;display:block}@media (max-width:767px){.breadcrumbs{z-index:1;position:relative}.djai-dropdown-menu{align-self:flex-start}.djai-dropdown-menu .djai-dropdown-menu__item{margin-top:3px}} 2 | /*# sourceMappingURL=admin-index.css.map */ 3 | -------------------------------------------------------------------------------- /django_admin_index/static/admin/css/admin-index.css.map: -------------------------------------------------------------------------------- 1 | {"mappings":"AE0BA,sbCvBA,uCAOA,gBAOA,kBAKA,iCAIA,gDChBA,yEFgBA,sbGRA,6FAEW,wCAYT,qJApBA,gLA+BE,kGA/BF,qOAyCE,mJAzCF,iZAkDI,0KAMJ,4OAoBA,0EAIE,4GAKA,kKArFF,kOAgGI,wFAMJ,yEAQF,yBAGE,yCAKA,0CAGE","sources":["admin-index.css","scss/admin-index.scss","scss/_vars.scss","scss/components/_containers.scss","scss/components/_header.scss","scss/components/_dropdown-menu.scss"],"sourcesContent":[":root {\n --djai-tab-bg: #4383a3;\n --djai-tab-bg--active: var(--primary);\n --djai-tab-bg--hover: var(--primary);\n --djai-tab-fg: var(--primary-fg);\n --djai-tab-fg--active: var(--primary-fg);\n --djai-tab-fg--hover: var(--primary-fg);\n --djai-dropdown-bg: var(--secondary);\n --djai-dropdown-bg--active: var(--primary);\n --djai-dropdown-bg--hover: var(--primary);\n --djai-dropdown-fg: var(--header-link-color);\n --djai-tablist-spacing-top: 10px;\n --djai-nav-sidebar-offset: 84px;\n}\n\n#container {\n min-height: 100%;\n height: auto;\n}\n\n#main {\n z-index: 1;\n}\n\n#header {\n z-index: 2;\n}\n\n.calendarbox, .clockbox {\n z-index: 1;\n}\n\n#nav-sidebar {\n top: var(--djai-nav-sidebar-offset);\n}\n\n#header {\n top: var(--djai-header-offset, 0);\n position: sticky;\n overflow: visible;\n}\n\n:root {\n --djai-tab-bg: #4383a3;\n --djai-tab-bg--active: var(--primary);\n --djai-tab-bg--hover: var(--primary);\n --djai-tab-fg: var(--primary-fg);\n --djai-tab-fg--active: var(--primary-fg);\n --djai-tab-fg--hover: var(--primary-fg);\n --djai-dropdown-bg: var(--secondary);\n --djai-dropdown-bg--active: var(--primary);\n --djai-dropdown-bg--hover: var(--primary);\n --djai-dropdown-fg: var(--header-link-color);\n --djai-tablist-spacing-top: 10px;\n --djai-nav-sidebar-offset: 84px;\n}\n\n.djai-dropdown-menu {\n margin-top: var(--djai-tablist-spacing-top);\n flex-basis: 100%;\n display: block;\n}\n\n#header {\n flex-wrap: wrap;\n padding-bottom: 0;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item {\n background-color: var(--djai-tab-bg);\n cursor: pointer;\n padding: 10px;\n display: inline-block;\n position: relative;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item, .djai-dropdown-menu .djai-dropdown-menu__item:link, .djai-dropdown-menu .djai-dropdown-menu__item:visited {\n color: var(--djai-tab-fg);\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item--active {\n background-color: var(--djai-tab-bg--active);\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item--active, .djai-dropdown-menu .djai-dropdown-menu__item--active:link, .djai-dropdown-menu .djai-dropdown-menu__item--active:visited {\n color: var(--djai-tab-fg--active);\n text-decoration: none;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item:hover, .djai-dropdown-menu .djai-dropdown-menu__item:focus {\n background-color: var(--djai-tab-bg--hover);\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item:hover, .djai-dropdown-menu .djai-dropdown-menu__item:hover:link, .djai-dropdown-menu .djai-dropdown-menu__item:hover:visited, .djai-dropdown-menu .djai-dropdown-menu__item:focus, .djai-dropdown-menu .djai-dropdown-menu__item:focus:link, .djai-dropdown-menu .djai-dropdown-menu__item:focus:visited {\n color: var(--djai-tab-fg--hover);\n text-decoration: none !important;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__item:hover .djai-dropdown-menu__drop, .djai-dropdown-menu .djai-dropdown-menu__item:focus .djai-dropdown-menu__drop {\n display: block;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__drop {\n min-width: 150px;\n z-index: 1;\n max-height: 75vh;\n background-color: var(--djai-dropdown-bg);\n display: none;\n position: absolute;\n top: 100%;\n left: 0;\n overflow-y: auto;\n box-shadow: 0 4px 8px #0003, 0 6px 20px #0003;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__drop-item {\n background-color: #0000;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__drop-item--active {\n background-color: var(--djai-dropdown-bg--active);\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__drop-item:hover, .djai-dropdown-menu .djai-dropdown-menu__drop-item:focus {\n background-color: var(--djai-dropdown-bg--hover);\n}\n\n#header .djai-dropdown-menu .djai-dropdown-menu__drop-item a, #header .djai-dropdown-menu .djai-dropdown-menu__drop-item a:link, #header .djai-dropdown-menu .djai-dropdown-menu__drop-item a:visited {\n color: var(--djai-dropdown-fg);\n}\n\n#header .djai-dropdown-menu .djai-dropdown-menu__drop-item a:hover {\n text-decoration: none;\n}\n\n.djai-dropdown-menu .djai-dropdown-menu__link {\n padding: 10px;\n display: block;\n}\n\n@media (max-width: 767px) {\n .breadcrumbs {\n z-index: 1;\n position: relative;\n }\n\n .djai-dropdown-menu {\n align-self: flex-start;\n }\n\n .djai-dropdown-menu .djai-dropdown-menu__item {\n margin-top: 3px;\n }\n}\n\n/*# sourceMappingURL=admin-index.css.map */\n","@import \"vars\";\n@import \"components/containers\";\n@import \"components/header\";\n@import \"components/dropdown-menu\";\n","/*\n Responsive styling. Breakpoints are taken from Django 3.2 admin/css/responsive.css.\n\n Note that you can not use CSS variables in media queries, so old-school sass variables\n it is here.\n*/\n$djai-breakpoint--mobile: 767px;\n$djai-breakpoint--tablet: 1024px;\n\n/* admin-index specific variables */\n\n// compensate for the sticky header by offsetting the nav sidebar. Default values\n// come from a clean django-startproject on Django 3.2 with the standard admin CSS.\n// If you use different fonts/paddings... etc, you can override this variable for your\n// own situation.\n$_default-nav-sidebar-offset: 10px + 28px + 46px; // heading padding top + branding height + dropdown-menu\n\n/*\n Define the CSS variable default values. These can be overridden by custom stylesheets,\n provided they are loaded AFTER the admin-index.css stylesheet is loaded OR they use\n a more specific selector than the :root pseudo-selector.\n\n We mostly refer to the built-in CSS variables from Django 3.2+ (see the reference in\n admin/css/base.css).\n*/\n\n:root {\n --djai-tab-bg: #4383a3;\n --djai-tab-bg--active: var(--primary);\n --djai-tab-bg--hover: var(--primary);\n --djai-tab-fg: var(--primary-fg);\n --djai-tab-fg--active: var(--primary-fg);\n --djai-tab-fg--hover: var(--primary-fg);\n\n --djai-dropdown-bg: var(--secondary);\n --djai-dropdown-bg--active: var(--primary);\n --djai-dropdown-bg--hover: var(--primary);\n --djai-dropdown-fg: var(--header-link-color);\n\n // default of 10px is the padding-bottom default of the header\n --djai-tablist-spacing-top: 10px;\n --djai-nav-sidebar-offset: #{$_default-nav-sidebar-offset};\n}\n\n// TODO: dark theme variables\n","/*\nOverride some containers to make the sticky header work with Django.\n */\n#container {\n // Django itself sets height: 100%, which causes the container to be less high than\n // the content and that in turn causes the sticky header to fall off the page.\n min-height: 100%;\n height: auto;\n}\n\n#main {\n // z-index is contextual, by specifying it explicitly here, we can have our header\n // use z-index relative to this one without having to fiddle with #main child element\n // z-indices.\n z-index: 1;\n}\n\n#header {\n z-index: 2; // overlay the #main container, which contains the sidebar on Django 3.2+\n}\n\n// Fix the calendar and time widgets \n.calendarbox, .clockbox {\n z-index: 1;\n}\n\n#nav-sidebar {\n top: var(--djai-nav-sidebar-offset);\n}\n","/*\nThe header is the container for our admin-index menu dropdown, which we make\nvisible at all times. For this, the header itself needs to receive a position fixed or\nsticky so that it stays in view when scrolling down vertically.\n\nIf the header itself is sticky and the container for the dropdowns, we don't need to\nmess with height offsets relative to the header/branding - which may not be pixel-\nperfect because of font-rendering shenanigans.\n*/\n\n#header {\n position: sticky;\n top: var(--djai-header-offset, 0);\n // the dropdown menu is a child and overflows\n overflow: visible;\n}\n","/*\nThe dropdown menu is injected as a child into the header, which has a flexbox display\nstyle (direction: row). This causes the menu to be on the same row as the branding and\nuser tools. However, we set the flex-basis to 100% to make it take up the full width\nof the container and let the parent wrap.\n */\n@use '../vars';\n\n$djai-block: 'djai-dropdown-menu';\n\n\n@mixin link-style() {\n &, &:link, &:visited {\n @content;\n }\n}\n\n\n.#{$djai-block} {\n // position it properly in the header as a separate row\n @at-root #header {\n flex-wrap: wrap;\n // remove the padding at the bottom so our dropdown items line up\n padding-bottom: 0;\n }\n\n flex-basis: 100%;\n\n // own layout\n display: block;\n margin-top: var(--djai-tablist-spacing-top);\n\n & &__item {\n display: inline-block;\n position: relative;\n padding: 10px;\n background-color: var(--djai-tab-bg);\n cursor: pointer;\n\n @include link-style {\n color: var(--djai-tab-fg);\n }\n\n &--active {\n background-color: var(--djai-tab-bg--active);\n\n @include link-style {\n text-decoration: none;\n color: var(--djai-tab-fg--active);\n }\n }\n\n // deliberately after the --active style so it overrides it\n &:hover,\n &:focus {\n background-color: var(--djai-tab-bg--hover);\n\n @include link-style {\n text-decoration: none !important;\n color: var(--djai-tab-fg--hover);\n }\n\n .#{$djai-block}__drop {\n display: block;\n }\n }\n }\n\n & &__drop {\n display: none; // hidden by default, visible by hovering over the parent\n\n // position it relative to the tab\n position: absolute;\n left: 0;\n top: 100%;\n min-width: 150px;\n z-index: 1;\n\n // ensure that very long lists have scrollable content\n max-height: 75vh;\n overflow-y: auto;\n\n // theme\n background-color: var(--djai-dropdown-bg);\n box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2),\n 0 6px 20px 0 rgba(0, 0, 0, 0.2);\n }\n\n & &__drop-item {\n\n background-color: transparent; // take the bg color of the dropdown block\n\n &--active {\n background-color: var(--djai-dropdown-bg--active);\n }\n\n // deliberately after the --active style so it overrides it\n &:hover,\n &:focus {\n background-color: var(--djai-dropdown-bg--hover);\n }\n\n // force a more specific rule so our own color overrides are used\n @at-root #header & a {\n @include link-style {\n color: var(--djai-dropdown-fg);\n }\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n\n & &__link {\n display: block;\n padding: 10px;\n }\n\n}\n\n// mobile styling\n@media (max-width: vars.$djai-breakpoint--mobile) {\n // ensure that the dropdown menus are on top of the breadcrumbs instead of hidden\n // behind them. The #header element has z-index 2.\n .breadcrumbs {\n position: relative;\n z-index: 1;\n }\n\n .#{$djai-block} {\n align-self: flex-start; // don't center, but align left\n\n & &__item {\n margin-top: 3px;\n }\n }\n}\n"],"names":[],"version":3,"file":"admin-index.css.map"} -------------------------------------------------------------------------------- /django_admin_index/templates/admin/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 8 | 9 | {% if not is_popup %} 10 | {% block breadcrumbs %} 11 | {% admin_index_settings as admin_index_settings %} 12 | 23 | {% endblock %} 24 | {% endif %} 25 | -------------------------------------------------------------------------------- /django_admin_index/templates/admin/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n admin_urls static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 8 | 9 | {% if not is_popup %} 10 | {% block breadcrumbs %} 11 | {% admin_index_settings as admin_index_settings %} 12 | 22 | {% endblock %} 23 | {% endif %} 24 | -------------------------------------------------------------------------------- /django_admin_index/templates/admin/delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/delete_confirmation.html" %} 2 | {% load i18n admin_urls static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block extrahead %} 8 | {# NOTE: Switched these 2 variables around to allow overrides #} 9 | {{ media }} 10 | {{ block.super }} 11 | 12 | {% endblock %} 13 | 14 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 15 | 16 | {% block breadcrumbs %} 17 | {% admin_index_settings as admin_index_settings %} 18 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /django_admin_index/templates/admin/delete_selected_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/delete_selected_confirmation.html" %} 2 | {% load i18n admin_urls static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block extrahead %} 8 | {# NOTE: Switched these 2 variables around to allow overrides #} 9 | {{ media }} 10 | {{ block.super }} 11 | 12 | {% endblock %} 13 | 14 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 15 | 16 | {% block breadcrumbs %} 17 | {% admin_index_settings as admin_index_settings %} 18 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /django_admin_index/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/index.html" %} 2 | {% load i18n static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 8 | 9 | {% block content %} 10 |
11 | 12 | {% dashboard_app_list as dashboard_app_list %} 13 | {% admin_index_settings as admin_index_settings %} 14 | 15 | {% if dashboard_app_list %} 16 | {% for app in dashboard_app_list %} 17 |
18 | 19 | 27 | {% for model in app.models %} 28 | 29 | {% if model.admin_url %} 30 | 31 | {% else %} 32 | 33 | {% endif %} 34 | 35 | {% if model.add_url %} 36 | 37 | {% else %} 38 | 39 | {% endif %} 40 | 41 | {% if model.admin_url %} 42 | {% if model.view_only %} 43 | 44 | {% else %} 45 | 46 | {% endif %} 47 | {% else %} 48 | 49 | {% endif %} 50 | 51 | {% endfor %} 52 |
20 | {# NOTE: Remove app groups if needed #} 21 | {% if admin_index_settings.HIDE_APP_INDEX_PAGES %} 22 | {{ app.name }} 23 | {% else %} 24 | {{ app.name }} 25 | {% endif %} 26 |
{{ model.name }}{{ model.name }}{% trans 'Add' %} {% trans 'View' %}{% trans 'Change' %} 
53 |
54 | {% endfor %} 55 | {% else %} 56 |

{% trans "You don't have permission to view or edit anything." %}

57 | {% endif %} 58 |
59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /django_admin_index/templates/admin/object_history.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/object_history.html" %} 2 | {% load i18n admin_urls static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 8 | 9 | {% block breadcrumbs %} 10 | {% admin_index_settings as admin_index_settings %} 11 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /django_admin_index/templates/django_admin_index/includes/app_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n django_admin_index static %} 2 | 3 | {% dashboard_app_list as dashboard_app_list %} 4 | {% display_dropdown_menu request as should_display_dropdown %} 5 | 6 |
7 | {% if dashboard_app_list and should_display_dropdown %} 8 | {% url 'admin:index' as home %} 9 | 11 | {% trans "Dashboard" %} 12 | 13 | {% for app in dashboard_app_list %} 14 |
15 | {{ app.name }} 16 | 17 | {% for model in app.models %} 18 | {% if model.admin_url %} 19 |
20 | {{ model.name }} 22 |
23 | {% endif %} 24 | {% endfor %} 25 |
26 |
27 | {% endfor %} 28 | {% endif %} 29 |
30 | -------------------------------------------------------------------------------- /django_admin_index/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/password_change_done.html" %} 2 | {% load static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 8 | -------------------------------------------------------------------------------- /django_admin_index/templates/registration/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/password_change_form.html" %} 2 | {% load static django_admin_index %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} 8 | -------------------------------------------------------------------------------- /django_admin_index/templates/registration/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/password_reset_complete.html" %} 2 | {% load static %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{{ block.super }} 8 | {% include "django_admin_index/includes/app_list.html" %} 9 | {% endblock nav-global %} 10 | -------------------------------------------------------------------------------- /django_admin_index/templates/registration/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/password_reset_confirm.html" %} 2 | {% load static %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{{ block.super }} 8 | {% include "django_admin_index/includes/app_list.html" %} 9 | {% endblock nav-global %} 10 | -------------------------------------------------------------------------------- /django_admin_index/templates/registration/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/password_reset_done.html" %} 2 | {% load static %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{{ block.super }} 8 | {% include "django_admin_index/includes/app_list.html" %} 9 | {% endblock nav-global %} 10 | -------------------------------------------------------------------------------- /django_admin_index/templates/registration/password_reset_form.html: -------------------------------------------------------------------------------- 1 | {% extends "registration/password_reset_form.html" %} 2 | {% load static %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 6 | 7 | {% block nav-global %}{{ block.super }} 8 | {% include "django_admin_index/includes/app_list.html" %} 9 | {% endblock nav-global %} 10 | -------------------------------------------------------------------------------- /django_admin_index/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/django_admin_index/templatetags/__init__.py -------------------------------------------------------------------------------- /django_admin_index/templatetags/django_admin_index.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import site 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.template import Library 4 | from django.utils.module_loading import import_string 5 | 6 | from ..conf import settings 7 | from ..models import AppGroup 8 | 9 | register = Library() 10 | 11 | 12 | @register.simple_tag(takes_context=True) 13 | def dashboard_app_list(context): 14 | try: 15 | request = context["request"] 16 | except KeyError: 17 | raise ImproperlyConfigured( 18 | "Django admin index requires 'django.template.context_processors.request' to be configured." 19 | ) 20 | 21 | # Get the new app_list. 22 | app_list = AppGroup.objects.as_list( 23 | request, settings.show_remaining_apps(request.user.is_superuser) 24 | ) 25 | 26 | # Use default app_list if there were no groups and no "misc" section. 27 | if not app_list: 28 | app_list = site.get_app_list(request) 29 | 30 | return app_list 31 | 32 | 33 | @register.simple_tag() 34 | def admin_index_settings(): 35 | return settings.as_dict() 36 | 37 | 38 | @register.simple_tag 39 | def display_dropdown_menu(request): 40 | func = import_string(settings.DISPLAY_DROP_DOWN_MENU_CONDITION_FUNCTION) 41 | return func(request) 42 | -------------------------------------------------------------------------------- /django_admin_index/translations.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | def validate_translation_json_format(value): 8 | if not isinstance(value, dict): 9 | raise ValidationError( 10 | _("The format of translations needs to be a JSON-object.") 11 | ) 12 | 13 | language_codes = [item[0] for item in settings.LANGUAGES] 14 | for key, val in value.items(): 15 | if key not in language_codes: 16 | raise ValidationError( 17 | _("The language code '{language_code}' is not enabled.").format( 18 | language_code=key 19 | ) 20 | ) 21 | 22 | if not isinstance(val, str): 23 | raise ValidationError( 24 | _( 25 | "The translation for language '{language_code}' is not a string." 26 | ).format(language_code=key) 27 | ) 28 | 29 | 30 | class TranslationsMixin(models.Model): 31 | translations = models.JSONField( 32 | _("translations"), 33 | default=dict, 34 | help_text=_( 35 | 'A JSON-object that uses the Django language code as key and the localized name as value. If no translation can be found for the active language, the name is used as fallback. Example: {"en": "File", "nl": "Bestand"}' 36 | ), 37 | validators=[validate_translation_json_format], 38 | ) 39 | 40 | class Meta: 41 | abstract = True 42 | -------------------------------------------------------------------------------- /django_admin_index/utils.py: -------------------------------------------------------------------------------- 1 | from django_admin_index.conf import settings 2 | 3 | 4 | def should_display_dropdown_menu(request): 5 | return ( 6 | settings.SHOW_MENU and request.user.is_authenticated and request.user.is_staff 7 | ) 8 | -------------------------------------------------------------------------------- /docs/_assets/application_groups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/docs/_assets/application_groups.png -------------------------------------------------------------------------------- /docs/_assets/application_groups_thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/docs/_assets/application_groups_thumb.png -------------------------------------------------------------------------------- /docs/_assets/change_user_management_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/docs/_assets/change_user_management_group.png -------------------------------------------------------------------------------- /docs/_assets/change_user_management_group_thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/docs/_assets/change_user_management_group_thumb.png -------------------------------------------------------------------------------- /docs/_assets/dashboard_with_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/docs/_assets/dashboard_with_menu.png -------------------------------------------------------------------------------- /docs/_assets/dashboard_with_menu_thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/docs/_assets/dashboard_with_menu_thumb.png -------------------------------------------------------------------------------- /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", "tests.proj.settings") 7 | 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as e: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?" 15 | ) from e 16 | 17 | execute_from_command_line(sys.argv) 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-admin-index", 3 | "version": "4.0.0", 4 | "description": "====================== Admin Index for Django ======================", 5 | "directories": { 6 | "test": "tests" 7 | }, 8 | "scripts": { 9 | "scss": "parcel build scss/admin-index.scss --dist-dir django_admin_index/static/admin/css/", 10 | "watch": "parcel watch scss/admin-index.scss --dist-dir django_admin_index/static/admin/css/" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/maykinmedia/django-admin-index.git" 15 | }, 16 | "author": "", 17 | "license": "BSD-3-Clause", 18 | "bugs": { 19 | "url": "https://github.com/maykinmedia/django-admin-index/issues" 20 | }, 21 | "homepage": "https://github.com/maykinmedia/django-admin-index#readme", 22 | "devDependencies": { 23 | "@parcel/transformer-sass": "^2.3.2", 24 | "parcel": "^2.3.2", 25 | "sass": "^1.26.10" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-admin-index" 7 | version = "4.0.0" 8 | description = "Admin index for Django" 9 | authors = [ 10 | {name = "Maykin Media", email = "support@maykinmedia.nl"} 11 | ] 12 | readme = "README.rst" 13 | license = {file = "LICENSE"} 14 | keywords = ["Django", "index", "dashboard"] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Framework :: Django", 18 | "Framework :: Django :: 4.2", 19 | "Intended Audience :: Developers", 20 | "Operating System :: Unix", 21 | "Operating System :: MacOS", 22 | "Operating System :: Microsoft :: Windows", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | requires-python = ">=3.10" 29 | dependencies = [ 30 | "django>=4.2", 31 | "django-ordered-model>=3.5", 32 | ] 33 | 34 | [project.urls] 35 | Homepage = "https://github.com/maykinmedia/django-admin-index" 36 | "Bug Tracker" = "https://github.com/maykinmedia/django-admin-index/issues" 37 | "Source Code" = "https://github.com/maykinmedia/django-admin-index" 38 | Changelog = "https://github.com/maykinmedia/django-admin-index/blob/main/CHANGELOG.rst" 39 | 40 | [project.optional-dependencies] 41 | tests = [ 42 | "pytest", 43 | "pytest-django", 44 | "tox", 45 | "isort", 46 | "black", 47 | "flake8", 48 | ] 49 | coverage = [ 50 | "pytest-cov", 51 | ] 52 | docs = [ 53 | "sphinx", 54 | "sphinx-rtd-theme", 55 | ] 56 | release = [ 57 | "bump-my-version", 58 | ] 59 | 60 | [tool.setuptools.packages.find] 61 | include = ["django_admin_index*"] 62 | namespaces = false 63 | 64 | [tool.isort] 65 | profile = "black" 66 | combine_as_imports = true 67 | known_django = "django" 68 | known_first_party="django_admin_index" 69 | sections=["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 70 | skip = ["env", ".tox", ".history"] 71 | 72 | [tool.pytest.ini_options] 73 | testpaths = ["tests/unit"] 74 | python_classes = ["test_*"] 75 | DJANGO_SETTINGS_MODULE = "tests.proj.settings" 76 | 77 | [tool.bumpversion] 78 | current_version = "4.0.0" 79 | files = [ 80 | {filename = "pyproject.toml"}, 81 | {filename = "README.rst"}, 82 | {filename = "django_admin_index/locale/nl/LC_MESSAGES/django.po"}, 83 | {filename = "package.json"}, 84 | {filename = "package-lock.json"}, 85 | ] 86 | 87 | [tool.coverage.run] 88 | branch = true 89 | source = [ 90 | "django_admin_index" 91 | ] 92 | omit = [ 93 | "django_admin_index/migrations/*", 94 | ] 95 | 96 | [tool.coverage.report] 97 | exclude_also = [ 98 | "if (typing\\.)?TYPE_CHECKING:", 99 | "@(typing\\.)?overload", 100 | "class .*\\(.*Protocol.*\\):", 101 | "@(abc\\.)?abstractmethod", 102 | "raise NotImplementedError", 103 | "\\.\\.\\.", 104 | "pass", 105 | ] 106 | omit = [ 107 | "django_admin_index/migrations/*", 108 | ] 109 | -------------------------------------------------------------------------------- /scss/_vars.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Responsive styling. Breakpoints are taken from Django 3.2 admin/css/responsive.css. 3 | 4 | Note that you can not use CSS variables in media queries, so old-school sass variables 5 | it is here. 6 | */ 7 | $djai-breakpoint--mobile: 767px; 8 | $djai-breakpoint--tablet: 1024px; 9 | 10 | /* admin-index specific variables */ 11 | 12 | // compensate for the sticky header by offsetting the nav sidebar. Default values 13 | // come from a clean django-startproject on Django 3.2 with the standard admin CSS. 14 | // If you use different fonts/paddings... etc, you can override this variable for your 15 | // own situation. 16 | $_default-nav-sidebar-offset: 10px + 28px + 46px; // heading padding top + branding height + dropdown-menu 17 | 18 | /* 19 | Define the CSS variable default values. These can be overridden by custom stylesheets, 20 | provided they are loaded AFTER the admin-index.css stylesheet is loaded OR they use 21 | a more specific selector than the :root pseudo-selector. 22 | 23 | We mostly refer to the built-in CSS variables from Django 3.2+ (see the reference in 24 | admin/css/base.css). 25 | */ 26 | 27 | :root { 28 | --djai-tab-bg: #4383a3; 29 | --djai-tab-bg--active: var(--primary); 30 | --djai-tab-bg--hover: var(--primary); 31 | --djai-tab-fg: var(--primary-fg); 32 | --djai-tab-fg--active: var(--primary-fg); 33 | --djai-tab-fg--hover: var(--primary-fg); 34 | 35 | --djai-dropdown-bg: var(--secondary); 36 | --djai-dropdown-bg--active: var(--primary); 37 | --djai-dropdown-bg--hover: var(--primary); 38 | --djai-dropdown-fg: var(--header-link-color); 39 | 40 | // default of 10px is the padding-bottom default of the header 41 | --djai-tablist-spacing-top: 10px; 42 | --djai-nav-sidebar-offset: #{$_default-nav-sidebar-offset}; 43 | } 44 | 45 | // TODO: dark theme variables 46 | -------------------------------------------------------------------------------- /scss/admin-index.scss: -------------------------------------------------------------------------------- 1 | @use "vars"; 2 | @import "components/containers"; 3 | @import "components/header"; 4 | @import "components/dropdown-menu"; 5 | -------------------------------------------------------------------------------- /scss/components/_containers.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Override some containers to make the sticky header work with Django. 3 | */ 4 | #container { 5 | // Django itself sets height: 100%, which causes the container to be less high than 6 | // the content and that in turn causes the sticky header to fall off the page. 7 | min-height: 100%; 8 | height: auto; 9 | } 10 | 11 | #main { 12 | // z-index is contextual, by specifying it explicitly here, we can have our header 13 | // use z-index relative to this one without having to fiddle with #main child element 14 | // z-indices. 15 | z-index: 1; 16 | } 17 | 18 | #header { 19 | z-index: 2; // overlay the #main container, which contains the sidebar on Django 3.2+ 20 | } 21 | 22 | // Fix the calendar and time widgets 23 | .calendarbox, .clockbox { 24 | z-index: 1; 25 | } 26 | 27 | #nav-sidebar { 28 | top: var(--djai-nav-sidebar-offset); 29 | } 30 | -------------------------------------------------------------------------------- /scss/components/_dropdown-menu.scss: -------------------------------------------------------------------------------- 1 | /* 2 | The dropdown menu is injected as a child into the header, which has a flexbox display 3 | style (direction: row). This causes the menu to be on the same row as the branding and 4 | user tools. However, we set the flex-basis to 100% to make it take up the full width 5 | of the container and let the parent wrap. 6 | */ 7 | @use '../vars'; 8 | 9 | $djai-block: 'djai-dropdown-menu'; 10 | 11 | 12 | @mixin link-style() { 13 | &, &:link, &:visited { 14 | @content; 15 | } 16 | } 17 | 18 | 19 | .#{$djai-block} { 20 | // position it properly in the header as a separate row 21 | @at-root #header { 22 | flex-wrap: wrap; 23 | // remove the padding at the bottom so our dropdown items line up 24 | padding-bottom: 0; 25 | } 26 | 27 | flex-basis: 100%; 28 | 29 | // own layout 30 | display: block; 31 | margin-top: var(--djai-tablist-spacing-top); 32 | 33 | & &__item { 34 | display: inline-block; 35 | position: relative; 36 | padding: 10px; 37 | background-color: var(--djai-tab-bg); 38 | cursor: pointer; 39 | 40 | @include link-style { 41 | color: var(--djai-tab-fg); 42 | } 43 | 44 | &--active { 45 | background-color: var(--djai-tab-bg--active); 46 | 47 | @include link-style { 48 | text-decoration: none; 49 | color: var(--djai-tab-fg--active); 50 | } 51 | } 52 | 53 | // deliberately after the --active style so it overrides it 54 | &:hover, 55 | &:focus { 56 | background-color: var(--djai-tab-bg--hover); 57 | 58 | @include link-style { 59 | text-decoration: none !important; 60 | color: var(--djai-tab-fg--hover); 61 | } 62 | 63 | .#{$djai-block}__drop { 64 | display: block; 65 | } 66 | } 67 | } 68 | 69 | & &__drop { 70 | display: none; // hidden by default, visible by hovering over the parent 71 | 72 | // position it relative to the tab 73 | position: absolute; 74 | left: 0; 75 | top: 100%; 76 | min-width: 150px; 77 | z-index: 1; 78 | 79 | // ensure that very long lists have scrollable content 80 | max-height: 75vh; 81 | overflow-y: auto; 82 | 83 | // theme 84 | background-color: var(--djai-dropdown-bg); 85 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 86 | 0 6px 20px 0 rgba(0, 0, 0, 0.2); 87 | } 88 | 89 | & &__drop-item { 90 | 91 | background-color: transparent; // take the bg color of the dropdown block 92 | 93 | &--active { 94 | background-color: var(--djai-dropdown-bg--active); 95 | } 96 | 97 | // deliberately after the --active style so it overrides it 98 | &:hover, 99 | &:focus { 100 | background-color: var(--djai-dropdown-bg--hover); 101 | } 102 | 103 | // force a more specific rule so our own color overrides are used 104 | @at-root #header & a { 105 | @include link-style { 106 | color: var(--djai-dropdown-fg); 107 | } 108 | 109 | &:hover { 110 | text-decoration: none; 111 | } 112 | } 113 | } 114 | 115 | & &__link { 116 | display: block; 117 | padding: 10px; 118 | } 119 | 120 | } 121 | 122 | // mobile styling 123 | @media (max-width: vars.$djai-breakpoint--mobile) { 124 | // ensure that the dropdown menus are on top of the breadcrumbs instead of hidden 125 | // behind them. The #header element has z-index 2. 126 | .breadcrumbs { 127 | position: relative; 128 | z-index: 1; 129 | } 130 | 131 | .#{$djai-block} { 132 | align-self: flex-start; // don't center, but align left 133 | 134 | & &__item { 135 | margin-top: 3px; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /scss/components/_header.scss: -------------------------------------------------------------------------------- 1 | /* 2 | The header is the container for our admin-index menu dropdown, which we make 3 | visible at all times. For this, the header itself needs to receive a position fixed or 4 | sticky so that it stays in view when scrolling down vertically. 5 | 6 | If the header itself is sticky and the container for the dropdowns, we don't need to 7 | mess with height offsets relative to the header/branding - which may not be pixel- 8 | perfect because of font-rendering shenanigans. 9 | */ 10 | 11 | #header { 12 | position: sticky; 13 | top: var(--djai-header-offset, 0); 14 | // the dropdown menu is a child and overflows 15 | overflow: visible; 16 | } 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/tests/__init__.py -------------------------------------------------------------------------------- /tests/proj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/tests/proj/__init__.py -------------------------------------------------------------------------------- /tests/proj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for Test project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | import sys 15 | 16 | from django.utils.translation import gettext_lazy as _ 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | 21 | sys.path.insert(0, os.path.abspath(os.path.join(BASE_DIR, os.pardir))) 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = "u($kbs9$irs0)436gbo9%!b&#zyd&70tx!n7!i&fl6qun@z1_l" 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS = ["*"] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = [ 38 | "django_admin_index", 39 | "ordered_model", 40 | "django.contrib.admin", 41 | "django.contrib.auth", 42 | "django.contrib.contenttypes", 43 | "django.contrib.sessions", 44 | "django.contrib.messages", 45 | "django.contrib.staticfiles", 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | "django.middleware.security.SecurityMiddleware", 50 | "django.contrib.sessions.middleware.SessionMiddleware", 51 | "django.middleware.common.CommonMiddleware", 52 | "django.middleware.csrf.CsrfViewMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | "django.contrib.messages.middleware.MessageMiddleware", 55 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 56 | ] 57 | 58 | ROOT_URLCONF = "tests.proj.urls" 59 | 60 | TEMPLATES = [ 61 | { 62 | "BACKEND": "django.template.backends.django.DjangoTemplates", 63 | "DIRS": [], 64 | "APP_DIRS": True, 65 | "OPTIONS": { 66 | "context_processors": [ 67 | "django.template.context_processors.debug", 68 | "django.template.context_processors.request", 69 | "django.contrib.auth.context_processors.auth", 70 | "django.contrib.messages.context_processors.messages", 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = "tests.proj.wsgi.application" 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 81 | 82 | DATABASES = { 83 | "default": { 84 | "ENGINE": "django.db.backends.sqlite3", 85 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 86 | "OPTIONS": { 87 | "timeout": 1000, 88 | }, 89 | } 90 | } 91 | 92 | CACHES = { 93 | "default": { 94 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 95 | }, 96 | "dummy": { 97 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 98 | }, 99 | } 100 | 101 | # Password validation 102 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 103 | 104 | django_auth = "django.contrib.auth.password_validation." 105 | 106 | AUTH_PASSWORD_VALIDATORS = [ 107 | { 108 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 109 | }, 110 | { 111 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 118 | }, 119 | ] 120 | 121 | 122 | # Internationalization 123 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 124 | 125 | LANGUAGE_CODE = "en" 126 | LANGUAGES = [("en", _("English")), ("nl", _("Dutch"))] 127 | 128 | TIME_ZONE = "UTC" 129 | 130 | USE_I18N = True 131 | 132 | USE_L10N = True 133 | 134 | USE_TZ = True 135 | 136 | 137 | # Static files (CSS, JavaScript, Images) 138 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 139 | 140 | STATIC_URL = "/static/" 141 | 142 | try: 143 | import debug_toolbar # noqa 144 | except ImportError: 145 | pass 146 | else: 147 | INSTALLED_APPS += ["debug_toolbar"] 148 | MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") 149 | 150 | INTERNAL_IPS = ["127.0.0.1"] 151 | -------------------------------------------------------------------------------- /tests/proj/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib import admin 3 | from django.urls import include, path 4 | 5 | urlpatterns = [ 6 | path("admin/", admin.site.urls), 7 | ] 8 | 9 | if apps.is_installed("debug_toolbar"): 10 | urlpatterns += [ 11 | path("__debug__/", include("debug_toolbar.urls")), 12 | ] 13 | -------------------------------------------------------------------------------- /tests/proj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for Test 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/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | from __future__ import absolute_import, unicode_literals 11 | 12 | import os 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.proj.settings") 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykinmedia/django-admin-index/e8dfb53a6df3a617d3033fbe4675811ea6127bb6/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_admin_index.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | from django.test import TestCase, override_settings 6 | 7 | from django_admin_index.apps import ( 8 | check_admin_index_app, 9 | check_admin_index_context_processor, 10 | check_request_context_processor, 11 | ) 12 | from django_admin_index.models import AppGroup, AppLink, ContentTypeProxy 13 | 14 | 15 | class AdminIndexTests(TestCase): 16 | def setUp(self): 17 | # Create new group... 18 | self.app_group = AppGroup.objects.create(name="My group", slug="my-group") 19 | # ...find the content type for model User (it needs to be registered in the admin) 20 | self.ct_user = ContentTypeProxy.objects.get(app_label="auth", model="user") 21 | # ...and this content type to the new group. 22 | self.app_group.models.add(self.ct_user) 23 | # ...and add a link to the same group. 24 | self.app_link = AppLink.objects.create( 25 | name="Support", link="https://www.maykinmedia.nl", app_group=self.app_group 26 | ) 27 | 28 | def test_app_group_str(self): 29 | self.assertEqual(str(self.app_group), "My group") 30 | 31 | def test_app_link_str(self): 32 | self.assertEqual(str(self.app_link), "Support") 33 | 34 | def test_app_content_type_proxy_str(self): 35 | self.assertEqual(str(self.ct_user), "auth.User") 36 | 37 | def test_check_admin_index_app_success(self): 38 | result = check_admin_index_app([]) 39 | self.assertEqual(len(result), 0) 40 | 41 | @override_settings( 42 | INSTALLED_APPS=["django_admin_index", "django.contrib.admin.apps.AdminConfig"] 43 | ) 44 | def test_check_admin_index_app_with_custom_admin_success(self): 45 | result = check_admin_index_app([]) 46 | self.assertEqual(len(result), 0) 47 | 48 | @override_settings(INSTALLED_APPS=["django.contrib.admin", "django_admin_index"]) 49 | def test_check_admin_index_app_after_admin_app(self): 50 | result = check_admin_index_app([]) 51 | self.assertEqual(len(result), 1) 52 | 53 | @override_settings( 54 | INSTALLED_APPS=["django.contrib.admin.apps.AdminConfig", "django_admin_index"] 55 | ) 56 | def test_check_admin_index_app_after_admin_app_with_custom_admin(self): 57 | result = check_admin_index_app([]) 58 | self.assertEqual(len(result), 1) 59 | 60 | @override_settings(INSTALLED_APPS=["django_admin_index"]) 61 | def test_check_admin_index_app_missing(self): 62 | result = check_admin_index_app([]) 63 | self.assertEqual(len(result), 1) 64 | 65 | @override_settings(TEMPLATES=[{"OPTIONS": {"context_processors": []}}]) 66 | def test_check_admin_index_context_process_not_present(self): 67 | result = check_admin_index_context_processor([]) 68 | self.assertEqual(len(result), 0) 69 | 70 | @override_settings( 71 | TEMPLATES=[ 72 | { 73 | "OPTIONS": { 74 | "context_processors": [ 75 | "django_admin_index.context_processors.dashboard" 76 | ] 77 | } 78 | } 79 | ] 80 | ) 81 | def test_check_admin_index_context_process_present(self): 82 | result = check_admin_index_context_processor([]) 83 | self.assertEqual(len(result), 1) 84 | 85 | @override_settings(TEMPLATES=[{}]) 86 | def test_check_admin_index_context_process_no_options(self): 87 | result = check_admin_index_context_processor([]) 88 | self.assertEqual(len(result), 0) 89 | 90 | @override_settings(TEMPLATES=[{"OPTIONS": {}}]) 91 | def test_check_admin_index_context_process_no_context_processors(self): 92 | result = check_admin_index_context_processor([]) 93 | self.assertEqual(len(result), 0) 94 | 95 | @override_settings(TEMPLATES=[{"OPTIONS": {"context_processors": []}}]) 96 | def test_check_request_context_process_missing(self): 97 | result = check_request_context_processor([]) 98 | self.assertEqual(len(result), 1) 99 | 100 | @override_settings( 101 | TEMPLATES=[ 102 | { 103 | "OPTIONS": { 104 | "context_processors": ["django.template.context_processors.request"] 105 | } 106 | } 107 | ] 108 | ) 109 | def test_check_request_context_process_present(self): 110 | result = check_request_context_processor([]) 111 | self.assertEqual(len(result), 0) 112 | 113 | @override_settings(TEMPLATES=[{}]) 114 | def test_check_request_context_process_no_options(self): 115 | result = check_request_context_processor([]) 116 | self.assertEqual(len(result), 1) 117 | 118 | @override_settings(TEMPLATES=[{"OPTIONS": {}}]) 119 | def test_check_request_context_process_no_context_processors(self): 120 | result = check_request_context_processor([]) 121 | self.assertEqual(len(result), 1) 122 | -------------------------------------------------------------------------------- /tests/unit/test_app_group.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import django 6 | from django.contrib.auth.models import AnonymousUser, Permission, User 7 | from django.test import RequestFactory, TestCase, override_settings 8 | 9 | from django_admin_index.conf import settings 10 | from django_admin_index.models import AppGroup, ContentTypeProxy 11 | from django_admin_index.templatetags.django_admin_index import dashboard_app_list 12 | 13 | if django.VERSION >= (1, 11): 14 | from django.urls import reverse 15 | else: 16 | from django.core.urlresolvers import reverse 17 | 18 | 19 | class AdminIndexAppGroupTests(TestCase): 20 | def setUp(self): 21 | # Create new group... 22 | self.app_group = AppGroup.objects.create(name="My group", slug="my-group") 23 | # ...find the content type for model User (it needs to be registered in the admin) 24 | self.ct_user = ContentTypeProxy.objects.get(app_label="auth", model="user") 25 | # ...and this content type to the new group. 26 | self.app_group.models.add(self.ct_user) 27 | 28 | # ...find the content type for model Group (it needs to be registered in the admin) and don't do anything 29 | # with it 30 | self.ct_group = ContentTypeProxy.objects.get(app_label="auth", model="group") 31 | 32 | self.factory = RequestFactory() 33 | 34 | self.superuser = self._create_user( 35 | username="superuser", is_staff=True, is_superuser=True 36 | ) 37 | 38 | def _create_user(self, **kwargs): 39 | options = { 40 | "username": "maykin", 41 | "email": "info@maykinmedia.nl", 42 | "password": "top_secret", 43 | "is_staff": False, 44 | "is_superuser": False, 45 | } 46 | options.update(kwargs) 47 | 48 | return User.objects._create_user(**options) 49 | 50 | @override_settings(ADMIN_INDEX_AUTO_CREATE_APP_GROUP=True) 51 | def test_only_create_group_when_enabled(self): 52 | request = self.factory.get(reverse("admin:index")) 53 | request.user = self.superuser 54 | 55 | self.assertEqual(AppGroup.objects.count(), 1) 56 | AppGroup.objects.as_list(request, False) 57 | self.assertEqual(AppGroup.objects.count(), 3) 58 | 59 | def test_dont_create_group_when_disabled(self): 60 | self.assertFalse(settings.AUTO_CREATE_APP_GROUP) 61 | 62 | request = self.factory.get(reverse("admin:index")) 63 | request.user = self.superuser 64 | 65 | self.assertEqual(AppGroup.objects.count(), 1) 66 | AppGroup.objects.as_list(request, False) 67 | self.assertEqual(AppGroup.objects.count(), 1) 68 | 69 | @override_settings(ADMIN_INDEX_AUTO_CREATE_APP_GROUP=True) 70 | def test_dont_update_existing_groups(self): 71 | # Create an "auth` group. The Group model should not be added. It would be if there was no group yet. 72 | app_group = AppGroup.objects.create(name="My group", slug="auth") 73 | self.assertEqual(app_group.models.count(), 0) 74 | 75 | request = self.factory.get(reverse("admin:index")) 76 | request.user = self.superuser 77 | 78 | AppGroup.objects.as_list(request, False) 79 | self.assertEqual(app_group.models.count(), 0) 80 | 81 | def test_as_list_without_include_remaining(self): 82 | request = self.factory.get(reverse("admin:index")) 83 | request.user = self.superuser 84 | 85 | result = AppGroup.objects.as_list(request, False) 86 | self.assertEqual(len(result), 1) 87 | 88 | app = result[0] 89 | 90 | self.assertEqual(app["app_label"], self.app_group.slug) 91 | self.assertEqual(len(app["models"]), 1) 92 | 93 | def test_as_list_with_include_remaining(self): 94 | request = self.factory.get(reverse("admin:index")) 95 | request.user = self.superuser 96 | 97 | result = AppGroup.objects.as_list(request, True) 98 | self.assertEqual(len(result), 2) 99 | 100 | self.assertSetEqual( 101 | set([a["app_label"] for a in result]), {self.app_group.slug, "misc"} 102 | ) 103 | 104 | app_my_group = [a for a in result if a["app_label"] == self.app_group.slug][0] 105 | self.assertEqual(len(app_my_group["models"]), 1) 106 | self.assertSetEqual( 107 | set(m["object_name"] for m in app_my_group["models"]), 108 | { 109 | "User", 110 | }, 111 | ) 112 | 113 | app_misc = [a for a in result if a["app_label"] == "misc"][0] 114 | self.assertEqual(len(app_misc["models"]), 2) 115 | self.assertSetEqual( 116 | set(m["object_name"] for m in app_misc["models"]), 117 | { 118 | "Group", 119 | "AppGroup", 120 | }, 121 | ) 122 | 123 | def test_as_list_active_menu_item(self): 124 | request = self.factory.get(reverse("admin:auth_user_changelist")) 125 | request.user = self.superuser 126 | 127 | result = AppGroup.objects.as_list(request, False) 128 | self.assertEqual(result[0]["models"][0]["name"], "Users") 129 | self.assertTrue(result[0]["models"][0]["active"]) 130 | 131 | def test_as_list_inactive_menu_item(self): 132 | request = self.factory.get(reverse("admin:index")) 133 | request.user = self.superuser 134 | 135 | result = AppGroup.objects.as_list(request, False) 136 | self.assertEqual(result[0]["models"][0]["name"], "Users") 137 | self.assertFalse(result[0]["models"][0]["active"]) 138 | 139 | def test_context_anonymous(self): 140 | request = self.factory.get(reverse("admin:index")) 141 | request.user = AnonymousUser() 142 | 143 | app_list = dashboard_app_list({"request": request}) 144 | self.assertEqual(len(app_list), 0) 145 | 146 | def test_context_user(self): 147 | request = self.factory.get(reverse("admin:index")) 148 | request.user = self._create_user() 149 | 150 | app_list = dashboard_app_list({"request": request}) 151 | self.assertEqual(len(app_list), 0) 152 | 153 | def test_context_staff_user_with_show_false(self): 154 | self.assertFalse(settings.SHOW_REMAINING_APPS) 155 | 156 | request = self.factory.get(reverse("admin:index")) 157 | user = self._create_user(is_staff=True) 158 | user.user_permissions.add(*Permission.objects.all()) 159 | request.user = user 160 | 161 | app_list = dashboard_app_list({"request": request}) 162 | self.assertEqual(len(app_list), 1) 163 | 164 | @override_settings(ADMIN_INDEX_SHOW_REMAINING_APPS=True) 165 | def test_context_staffuser_with_show_true(self): 166 | request = self.factory.get(reverse("admin:index")) 167 | user = self._create_user(is_staff=True) 168 | user.user_permissions.add(*Permission.objects.all()) 169 | request.user = user 170 | 171 | app_list = dashboard_app_list({"request": request}) 172 | self.assertEqual(len(app_list), 2) 173 | 174 | def test_context_superuser_with_show_true(self): 175 | self.assertTrue(settings.SHOW_REMAINING_APPS_TO_SUPERUSERS) 176 | 177 | request = self.factory.get(reverse("admin:index")) 178 | request.user = self.superuser 179 | 180 | app_list = dashboard_app_list({"request": request}) 181 | self.assertEqual(len(app_list), 2) 182 | 183 | @override_settings(ADMIN_INDEX_SHOW_REMAINING_APPS_TO_SUPERUSERS=False) 184 | def test_context_superuser_with_show_false(self): 185 | request = self.factory.get(reverse("admin:index")) 186 | request.user = self.superuser 187 | 188 | app_list = dashboard_app_list({"request": request}) 189 | self.assertEqual(len(app_list), 1) 190 | 191 | def test_natural_key(self): 192 | obj = AppGroup.objects.get_by_natural_key(self.app_group.slug) 193 | self.assertEqual(obj.natural_key(), (self.app_group.slug,)) 194 | 195 | @override_settings(LANGUAGE_CODE="en") 196 | def test_en_localized_app_group_name_is_returned(self): 197 | self.app_group.translations = {"en": "My group", "nl": "Mijn groep"} 198 | self.app_group.save() 199 | 200 | request = self.factory.get(reverse("admin:index")) 201 | request.user = self.superuser 202 | app_group = AppGroup.objects.filter(id=self.app_group.id).as_list( 203 | request, False 204 | ) 205 | 206 | self.assertEqual(app_group[0]["name"], "My group") 207 | 208 | @override_settings(LANGUAGE_CODE="nl") 209 | def test_nl_localized_app_group_name_is_returned(self): 210 | self.app_group.translations = {"en": "My group", "nl": "Mijn groep"} 211 | self.app_group.save() 212 | 213 | request = self.factory.get(reverse("admin:index")) 214 | request.user = self.superuser 215 | app_group = AppGroup.objects.filter(id=self.app_group.id).as_list( 216 | request, False 217 | ) 218 | 219 | self.assertEqual(app_group[0]["name"], "Mijn groep") 220 | -------------------------------------------------------------------------------- /tests/unit/test_app_link.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import django 6 | from django.contrib.auth.models import AnonymousUser, Permission, User 7 | from django.test import RequestFactory, TestCase, override_settings 8 | 9 | from django_admin_index.models import AppGroup, AppLink 10 | from django_admin_index.templatetags.django_admin_index import dashboard_app_list 11 | 12 | if django.VERSION >= (1, 11): 13 | from django.urls import reverse 14 | else: 15 | from django.core.urlresolvers import reverse 16 | 17 | 18 | class AdminIndexAppLinkTests(TestCase): 19 | def setUp(self): 20 | # Create new group... 21 | self.app_group = AppGroup.objects.create(name="My group", slug="my-group") 22 | # ...and add a link to the same group. 23 | self.app_link = AppLink.objects.create( 24 | name="Support", link="https://www.maykinmedia.nl", app_group=self.app_group 25 | ) 26 | 27 | self.factory = RequestFactory() 28 | 29 | self.superuser = self._create_user( 30 | username="superuser", is_staff=True, is_superuser=True 31 | ) 32 | 33 | def _create_user(self, **kwargs): 34 | options = { 35 | "username": "maykin", 36 | "email": "info@maykinmedia.nl", 37 | "password": "top_secret", 38 | "is_staff": False, 39 | "is_superuser": False, 40 | } 41 | options.update(kwargs) 42 | 43 | return User.objects._create_user(**options) 44 | 45 | def test_as_list_structure(self): 46 | request = self.factory.get(reverse("admin:index")) 47 | request.user = self.superuser 48 | 49 | result = AppGroup.objects.as_list(request, False) 50 | self.assertEqual(len(result), 1) 51 | 52 | app = result[0] 53 | app_model = app["models"][0] 54 | 55 | self.assertEqual( 56 | app_model, 57 | { 58 | "active": False, 59 | "name": self.app_link.name, 60 | "app_label": self.app_group.slug, 61 | "admin_url": self.app_link.link, 62 | "view_only": True, 63 | }, 64 | ) 65 | 66 | def test_as_list_without_include_remaining(self): 67 | request = self.factory.get(reverse("admin:index")) 68 | request.user = self.superuser 69 | 70 | result = AppGroup.objects.as_list(request, False) 71 | self.assertEqual(len(result), 1) 72 | 73 | app = result[0] 74 | 75 | self.assertEqual(app["app_label"], self.app_group.slug) 76 | self.assertEqual(len(app["models"]), 1) 77 | 78 | def test_as_list_with_include_remaining(self): 79 | request = self.factory.get(reverse("admin:index")) 80 | request.user = self.superuser 81 | 82 | result = AppGroup.objects.as_list(request, True) 83 | self.assertEqual(len(result), 2) 84 | 85 | self.assertSetEqual( 86 | set([a["app_label"] for a in result]), {self.app_group.slug, "misc"} 87 | ) 88 | 89 | app_my_group = [a for a in result if a["app_label"] == self.app_group.slug][0] 90 | self.assertEqual(len(app_my_group["models"]), 1) 91 | self.assertSetEqual( 92 | set(m["name"] for m in app_my_group["models"]), 93 | { 94 | "Support", 95 | }, 96 | ) 97 | 98 | app_misc = [a for a in result if a["app_label"] == "misc"][0] 99 | self.assertEqual(len(app_misc["models"]), 3) 100 | self.assertSetEqual( 101 | set(m["object_name"] for m in app_misc["models"]), 102 | { 103 | "User", 104 | "Group", 105 | "AppGroup", 106 | }, 107 | ) 108 | 109 | def test_context_anonymous(self): 110 | request = self.factory.get(reverse("admin:index")) 111 | request.user = AnonymousUser() 112 | 113 | app_list = dashboard_app_list({"request": request}) 114 | # The AppLink is shown to everyone. There are no permissions set. 115 | self.assertEqual(len(app_list), 1) 116 | 117 | def test_context_user(self): 118 | request = self.factory.get(reverse("admin:index")) 119 | request.user = self._create_user() 120 | 121 | app_list = dashboard_app_list({"request": request}) 122 | # The AppLink is shown to everyone. There are no permissions set. 123 | self.assertEqual(len(app_list), 1) 124 | 125 | def test_context_staff_user(self): 126 | request = self.factory.get(reverse("admin:index")) 127 | user = self._create_user(is_staff=True) 128 | user.user_permissions.add(*Permission.objects.all()) 129 | request.user = user 130 | 131 | app_list = dashboard_app_list({"request": request}) 132 | self.assertEqual(len(app_list), 1) 133 | 134 | @override_settings(ADMIN_INDEX_SHOW_REMAINING_APPS=True) 135 | def test_context_staffuser_with_show_true(self): 136 | request = self.factory.get(reverse("admin:index")) 137 | user = self._create_user(is_staff=True) 138 | user.user_permissions.add(*Permission.objects.all()) 139 | request.user = user 140 | 141 | app_list = dashboard_app_list({"request": request}) 142 | self.assertEqual(len(app_list), 2) 143 | 144 | def test_context_superuser(self): 145 | request = self.factory.get(reverse("admin:index")) 146 | request.user = self.superuser 147 | 148 | app_list = dashboard_app_list({"request": request}) 149 | self.assertEqual(len(app_list), 2) 150 | 151 | @override_settings(ADMIN_INDEX_SHOW_REMAINING_APPS_TO_SUPERUSERS=False) 152 | def test_context_superuser_with_show_false(self): 153 | request = self.factory.get(reverse("admin:index")) 154 | request.user = self.superuser 155 | 156 | app_list = dashboard_app_list({"request": request}) 157 | self.assertEqual(len(app_list), 1) 158 | 159 | def test_dashboard_active_link_only_delete_permission(self): 160 | self.app_link.link = "/admin/auth" 161 | self.app_link.save() 162 | 163 | user = self._create_user(username="test", is_staff=True) 164 | permission = Permission.objects.get(name="Can delete user") 165 | user.user_permissions.add(permission) 166 | 167 | request = self.factory.get(reverse("admin:index")) 168 | request.user = user 169 | 170 | result = AppGroup.objects.as_list(request, False) 171 | 172 | self.assertEqual(len(result), 1) 173 | 174 | app = result[0] 175 | app_model = app["models"][0] 176 | 177 | self.assertEqual( 178 | app_model, 179 | { 180 | "active": False, 181 | "name": self.app_link.name, 182 | "app_label": self.app_group.slug, 183 | "admin_url": self.app_link.link, 184 | "view_only": True, 185 | }, 186 | ) 187 | 188 | def test_dashboard_active_link_only_add_permission(self): 189 | self.app_link.link = "/admin/auth" 190 | self.app_link.save() 191 | 192 | user = self._create_user(username="test", is_staff=True) 193 | permission = Permission.objects.get(name="Can add user") 194 | user.user_permissions.add(permission) 195 | 196 | request = self.factory.get(reverse("admin:auth_user_add")) 197 | request.user = user 198 | 199 | result = AppGroup.objects.as_list(request, False) 200 | 201 | self.assertEqual(len(result), 1) 202 | 203 | app = result[0] 204 | app_model = app["models"][0] 205 | 206 | self.assertEqual( 207 | app_model, 208 | { 209 | "active": True, 210 | "name": self.app_link.name, 211 | "app_label": self.app_group.slug, 212 | "admin_url": self.app_link.link, 213 | "view_only": True, 214 | }, 215 | ) 216 | 217 | def test_dashboard_active_link_only_change_permission(self): 218 | self.app_link.link = "/admin/auth" 219 | self.app_link.save() 220 | 221 | user = self._create_user(username="test", is_staff=True) 222 | permission = Permission.objects.get(name="Can change user") 223 | user.user_permissions.add(permission) 224 | 225 | request = self.factory.get(reverse("admin:auth_user_changelist")) 226 | request.user = user 227 | 228 | result = AppGroup.objects.as_list(request, False) 229 | 230 | self.assertEqual(len(result), 1) 231 | 232 | app = result[0] 233 | app_model = app["models"][0] 234 | 235 | self.assertEqual( 236 | app_model, 237 | { 238 | "active": True, 239 | "name": self.app_link.name, 240 | "app_label": self.app_group.slug, 241 | "admin_url": self.app_link.link, 242 | "view_only": True, 243 | }, 244 | ) 245 | 246 | def test_natural_key(self): 247 | obj = AppLink.objects.get_by_natural_key(self.app_group, self.app_link.link) 248 | self.assertEqual(obj.natural_key(), (self.app_group, self.app_link.link)) 249 | -------------------------------------------------------------------------------- /tests/unit/test_integration.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | from unittest import skipIf 6 | 7 | import django 8 | from django.contrib.auth.models import Permission, User 9 | from django.test import TestCase, override_settings 10 | 11 | from django_admin_index.conf import settings 12 | from django_admin_index.models import AppGroup, ContentTypeProxy 13 | 14 | if django.VERSION >= (1, 11): 15 | from django.urls import reverse 16 | else: 17 | from django.core.urlresolvers import reverse 18 | 19 | 20 | class AdminIndexIntegrationTests(TestCase): 21 | def setUp(self): 22 | self.superuser = User.objects._create_user( 23 | username="superuser", 24 | email="user@example.com", 25 | password="top_secret", 26 | is_staff=True, 27 | is_superuser=True, 28 | ) 29 | self.assertTrue( 30 | self.client.login(username=self.superuser.username, password="top_secret") 31 | ) 32 | 33 | self.auth_app_list_url = reverse("admin:app_list", kwargs={"app_label": "auth"}) 34 | 35 | def test_app_groups_in_context(self): 36 | response = self.client.get(reverse("admin:index")) 37 | 38 | self.assertIn("dashboard_app_list", response.context) 39 | self.assertGreater(len(response.context["dashboard_app_list"]), 0) 40 | 41 | def test_app_groups_in_context_outside_index(self): 42 | response = self.client.get(reverse("admin:auth_user_changelist")) 43 | 44 | self.assertIn("dashboard_app_list", response.context) 45 | 46 | @skipIf(django.VERSION < (1, 9), "Django < 1.9 does not support template origins.") 47 | def test_app_groups_in_index(self): 48 | response = self.client.get(reverse("admin:index")) 49 | 50 | template_path = response.templates[0].origin.name.replace("\\", "/") 51 | self.assertTrue( 52 | template_path.endswith("django_admin_index/templates/admin/index.html"), 53 | template_path, 54 | ) 55 | 56 | @skipIf( 57 | django.VERSION > (3, 0), "Django > 3.0 includes a sidebar with app list link." 58 | ) 59 | def test_no_app_list_links_index(self): 60 | response = self.client.get(reverse("admin:index")) 61 | html = response.content.decode("utf-8") 62 | 63 | self.assertNotIn('{}"'.format(self.auth_app_list_url), html) 64 | 65 | @skipIf( 66 | django.VERSION > (3, 0), "Django > 3.0 includes a sidebar with app list link." 67 | ) 68 | def test_no_app_list_links_change_form(self): 69 | response = self.client.get( 70 | reverse("admin:auth_user_change", args=(self.superuser.pk,)) 71 | ) 72 | html = response.content.decode("utf-8") 73 | 74 | self.assertNotIn('{}"'.format(self.auth_app_list_url), html) 75 | 76 | @skipIf( 77 | django.VERSION > (3, 0), "Django > 3.0 includes a sidebar with app list link." 78 | ) 79 | def test_no_app_list_links_change_list(self): 80 | response = self.client.get(reverse("admin:auth_user_changelist")) 81 | html = response.content.decode("utf-8") 82 | 83 | self.assertNotIn('{}"'.format(self.auth_app_list_url), html) 84 | 85 | @skipIf( 86 | django.VERSION > (3, 0), "Django > 3.0 includes a sidebar with app list link." 87 | ) 88 | def test_no_app_list_links_delete_confirmation(self): 89 | response = self.client.get( 90 | reverse("admin:auth_user_delete", args=(self.superuser.pk,)) 91 | ) 92 | html = response.content.decode("utf-8") 93 | 94 | self.assertNotIn('{}"'.format(self.auth_app_list_url), html) 95 | 96 | @skipIf( 97 | django.VERSION > (3, 0), "Django > 3.0 includes a sidebar with app list link." 98 | ) 99 | def test_no_app_list_links_delete_selected_confirmation(self): 100 | response = self.client.post( 101 | reverse("admin:auth_user_changelist"), 102 | data={ 103 | "action": "delete_selected", 104 | "select_across": 0, 105 | "index": 0, 106 | "_selected_action": self.superuser.pk, 107 | }, 108 | ) 109 | html = response.content.decode("utf-8") 110 | 111 | self.assertNotIn('{}"'.format(self.auth_app_list_url), html) 112 | 113 | @skipIf( 114 | django.VERSION > (3, 0), "Django > 3.0 includes a sidebar with app list link." 115 | ) 116 | def test_no_app_list_links_object_history(self): 117 | response = self.client.get( 118 | reverse("admin:auth_user_history", args=(self.superuser.pk,)) 119 | ) 120 | html = response.content.decode("utf-8") 121 | 122 | self.assertNotIn('{}"'.format(self.auth_app_list_url), html) 123 | 124 | @override_settings(SHOW_REMAINING_APPS_TO_SUPERUSERS=False) 125 | def test_show_apps_when_superuser_and_setting_disabled_and_no_app_groups(self): 126 | response = self.client.get(reverse("admin:index")) 127 | self.assertGreater(len(response.context["dashboard_app_list"]), 0) 128 | 129 | def test_show_apps_when_staff_user_and_setting_disabled_and_no_app_groups(self): 130 | self.assertFalse(settings.SHOW_REMAINING_APPS) 131 | 132 | staff_user = User.objects._create_user( 133 | username="staff_user", 134 | email="staffuser@example.com", 135 | password="top_secret", 136 | is_staff=True, 137 | is_superuser=False, 138 | ) 139 | for perm in Permission.objects.all(): 140 | staff_user.user_permissions.add(perm) 141 | 142 | self.assertTrue( 143 | self.client.login(username=staff_user.username, password="top_secret") 144 | ) 145 | 146 | response = self.client.get(reverse("admin:index")) 147 | self.assertGreater(len(response.context["dashboard_app_list"]), 0) 148 | 149 | def test_apps_with_only_add_perm(self): 150 | staff_user = User.objects._create_user( 151 | username="staff_user", 152 | email="staffuser@example.com", 153 | password="top_secret", 154 | is_staff=True, 155 | is_superuser=False, 156 | ) 157 | perm = Permission.objects.get(codename="add_group") 158 | staff_user.user_permissions.add(perm) 159 | 160 | self.assertTrue( 161 | self.client.login(username=staff_user.username, password="top_secret") 162 | ) 163 | 164 | response = self.client.get(reverse("admin:index")) 165 | self.assertNotContains(response, 'href="None"') 166 | 167 | 168 | class AdminIndexLanguageTests(TestCase): 169 | def setUp(self): 170 | # Create new group... 171 | self.app_group = AppGroup.objects.create( 172 | name="My group", 173 | slug="my-group", 174 | translations={"en": "My group", "nl": "Mijn groep"}, 175 | ) 176 | # ...find the content type for model User (it needs to be registered in the admin) 177 | self.ct_user = ContentTypeProxy.objects.get(app_label="auth", model="user") 178 | # ...and this content type to the new group. 179 | self.app_group.models.add(self.ct_user) 180 | self.superuser = User.objects._create_user( 181 | username="superuser", 182 | email="user@example.com", 183 | password="top_secret", 184 | is_staff=True, 185 | is_superuser=True, 186 | ) 187 | self.assertTrue( 188 | self.client.login(username=self.superuser.username, password="top_secret") 189 | ) 190 | 191 | @override_settings(LANGUAGE_CODE="en") 192 | def test_en_text_is_rendered_when_selected(self): 193 | response = self.client.get(reverse("admin:index")) 194 | html = response.content.decode("utf-8") 195 | 196 | self.assertIn("My group", html) 197 | self.assertNotIn("Mijn groep", html) 198 | 199 | @override_settings(LANGUAGE_CODE="nl") 200 | def test_nl_text_is_rendered_when_selected(self): 201 | response = self.client.get(reverse("admin:index")) 202 | html = response.content.decode("utf-8") 203 | 204 | self.assertIn("Mijn groep", html) 205 | self.assertNotIn("My group", html) 206 | -------------------------------------------------------------------------------- /tests/unit/test_validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.test import TestCase 3 | from django.utils.translation import gettext as _ 4 | 5 | from django_admin_index.translations import validate_translation_json_format 6 | 7 | 8 | class ValidatorsTests(TestCase): 9 | def test_valid_json_obj_success(self): 10 | self.assertIsNone( 11 | validate_translation_json_format({"en": "My profile", "nl": "Mijn profiel"}) 12 | ) 13 | 14 | def test_not_dict_value_fails(self): 15 | self.assertRaisesMessage( 16 | ValidationError, 17 | _("The format of translations needs to be a JSON-object."), 18 | validate_translation_json_format, 19 | "not a dict", 20 | ) 21 | 22 | def test_disabled_language_fails(self): 23 | self.assertRaisesMessage( 24 | ValidationError, 25 | _("The language code '{language_code}' is not enabled.").format( 26 | language_code="es" 27 | ), 28 | validate_translation_json_format, 29 | {"es": "Mi perfil"}, 30 | ) 31 | 32 | def test_not_str_translation_text_fails(self): 33 | self.assertRaisesMessage( 34 | ValidationError, 35 | _("The translation for language '{language_code}' is not a string.").format( 36 | language_code="en" 37 | ), 38 | validate_translation_json_format, 39 | {"en": 2}, 40 | ) 41 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{310,311,312}-django42 4 | isort 5 | black 6 | flake8 7 | skip_missing_interpreters = true 8 | 9 | [gh-actions] 10 | python = 11 | 3.10: py310 12 | 3.11: py311 13 | 3.12: py312 14 | 15 | [gh-actions:env] 16 | DJANGO = 17 | 4.2: django42 18 | 19 | [testenv] 20 | setenv = 21 | DJANGO_SETTINGS_MODULE=tests.proj.settings 22 | PYTHONPATH={toxinidir} 23 | extras = 24 | tests 25 | coverage 26 | deps = 27 | django42: Django~=4.2.0 28 | 29 | commands = 30 | pytest -v \ 31 | --cov-report=term \ 32 | --cov-report xml:reports/coverage-{envname}.xml \ 33 | {posargs} 34 | 35 | [testenv:isort] 36 | extras = tests 37 | skipsdist = True 38 | commands = isort --check-only --diff . 39 | 40 | [testenv:black] 41 | extras = tests 42 | skipsdist = True 43 | commands = black --check django_admin_index tests 44 | 45 | [testenv:flake8] 46 | extras = tests 47 | skipsdist = True 48 | commands = flake8 . 49 | --------------------------------------------------------------------------------