├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── build_and_test.yml ├── .gitignore ├── .pylintrc ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── django_custom_admin_pages ├── __init__.py ├── admin.py ├── apps.py ├── boot_django.py ├── conftest.py ├── default_settings.py ├── exceptions.py ├── pytest.ini ├── templates │ └── base_custom_admin.html ├── tests │ ├── __init__.py │ ├── test_custom_admin_pages.py │ └── test_urls.py ├── urls.py └── views │ ├── __init__.py │ └── admin_base_view.py ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── api.rst │ ├── conf.py │ ├── index.rst │ ├── static │ └── example_view.png │ └── usage.rst ├── poetry.lock ├── pyproject.toml ├── test_proj ├── __init__.py ├── another_test_app │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── asgi.py ├── manage.py ├── settings.py ├── test_app │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ └── views │ │ ├── __init__.py │ │ └── example_view.py ├── urls.py └── wsgi.py └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-python@v4 11 | with: 12 | python-version: "3.10" 13 | - uses: Gr1N/setup-poetry@v8 14 | - uses: actions/cache@v4 15 | with: 16 | path: ~/.cache/pypoetry/virtualenvs 17 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} 18 | - name: Restore Poetry cache 19 | run: poetry config virtualenvs.create false 20 | - name: Install Dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install poetry 24 | poetry --version 25 | poetry install --with dev 26 | - name: Analyze Code with Pylint 27 | run: | 28 | poetry run pylint --load-plugins=pylint_django ./django_custom_admin_pages/ 29 | - name: Analyzing code with black 30 | run: | 31 | poetry run black . --check 32 | - name: Analyzing code with isort 33 | run: | 34 | poetry run isort . --check-only --skip docs 35 | 36 | test: 37 | strategy: 38 | matrix: 39 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 40 | django: ['32', '40', '41', '42'] 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v3 44 | - uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install Dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install tox 51 | - name: Run unittests 52 | env: 53 | TOX_ENV: py${{ matrix.python-version}}-django${{ matrix.django }} 54 | run: | 55 | tox -e $TOX_ENV 56 | upload-codecov: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v3 60 | - uses: actions/setup-python@v4 61 | with: 62 | python-version: "3.10" 63 | - name: Install Dependencies 64 | run: | 65 | pip install --upgrade pip 66 | pip install django==4.2 pytest pytest-cov pytest-django coverage 67 | - name: Run Unit Tests 68 | run: | 69 | cd django_custom_admin_pages 70 | pytest --cov=./ --cov-report=xml --create-db 71 | - name: Upload coverage reports to Codecov 72 | uses: codecov/codecov-action@v3 73 | with: 74 | token: ${{ secrets.CODECOV_TOKEN }} 75 | files: .coverage,coverage.xml 76 | 77 | env: 78 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | media/ 2 | test_media/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # node 13 | node_modules/ 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # .python-version 88 | 89 | # celery beat schedule file 90 | celerybeat-schedule 91 | 92 | # SageMath parsed files 93 | *.sage.py 94 | 95 | # Environments 96 | .env 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | #config file 123 | .cnf 124 | 125 | .vscode 126 | 127 | temp 128 | docs/source/generated -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS, migrations, tests 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins=pylint_django 85 | 86 | ; django-settings-module=test_proj.settings 87 | 88 | # Pickle collected data for later comparisons. 89 | persistent=yes 90 | 91 | # Minimum Python version to use for version dependent checks. Will default to 92 | # the version used to run pylint. 93 | py-version=3.10 94 | 95 | # Discover python modules and packages in the file system subtree. 96 | recursive=no 97 | 98 | # Add paths to the list of the source roots. Supports globbing patterns. The 99 | # source root is an absolute path or a path relative to the current working 100 | # directory used to determine a package namespace for modules located under the 101 | # source root. 102 | source-roots=src 103 | 104 | # When enabled, pylint would attempt to guess common misconfiguration and emit 105 | # user-friendly hints instead of false-positive error messages. 106 | suggestion-mode=yes 107 | 108 | # Allow loading of arbitrary C extensions. Extensions are imported into the 109 | # active Python interpreter and may run arbitrary code. 110 | unsafe-load-any-extension=no 111 | 112 | # In verbose mode, extra non-checker-related info will be displayed. 113 | #verbose= 114 | 115 | 116 | [BASIC] 117 | 118 | # Naming style matching correct argument names. 119 | argument-naming-style=snake_case 120 | 121 | # Regular expression matching correct argument names. Overrides argument- 122 | # naming-style. If left empty, argument names will be checked with the set 123 | # naming style. 124 | #argument-rgx= 125 | 126 | # Naming style matching correct attribute names. 127 | attr-naming-style=snake_case 128 | 129 | # Regular expression matching correct attribute names. Overrides attr-naming- 130 | # style. If left empty, attribute names will be checked with the set naming 131 | # style. 132 | #attr-rgx= 133 | 134 | # Bad variable names which should always be refused, separated by a comma. 135 | bad-names=foo, 136 | bar, 137 | baz, 138 | toto, 139 | tutu, 140 | tata 141 | 142 | # Bad variable names regexes, separated by a comma. If names match any regex, 143 | # they will always be refused 144 | bad-names-rgxs= 145 | 146 | # Naming style matching correct class attribute names. 147 | class-attribute-naming-style=any 148 | 149 | # Regular expression matching correct class attribute names. Overrides class- 150 | # attribute-naming-style. If left empty, class attribute names will be checked 151 | # with the set naming style. 152 | #class-attribute-rgx= 153 | 154 | # Naming style matching correct class constant names. 155 | class-const-naming-style=UPPER_CASE 156 | 157 | # Regular expression matching correct class constant names. Overrides class- 158 | # const-naming-style. If left empty, class constant names will be checked with 159 | # the set naming style. 160 | #class-const-rgx= 161 | 162 | # Naming style matching correct class names. 163 | class-naming-style=PascalCase 164 | 165 | # Regular expression matching correct class names. Overrides class-naming- 166 | # style. If left empty, class names will be checked with the set naming style. 167 | #class-rgx= 168 | 169 | # Naming style matching correct constant names. 170 | const-naming-style=UPPER_CASE 171 | 172 | # Regular expression matching correct constant names. Overrides const-naming- 173 | # style. If left empty, constant names will be checked with the set naming 174 | # style. 175 | #const-rgx= 176 | 177 | # Minimum line length for functions/classes that require docstrings, shorter 178 | # ones are exempt. 179 | docstring-min-length=-1 180 | 181 | # Naming style matching correct function names. 182 | function-naming-style=snake_case 183 | 184 | # Regular expression matching correct function names. Overrides function- 185 | # naming-style. If left empty, function names will be checked with the set 186 | # naming style. 187 | #function-rgx= 188 | 189 | # Good variable names which should always be accepted, separated by a comma. 190 | good-names=i, 191 | j, 192 | k, 193 | ex, 194 | Run, 195 | _ 196 | 197 | # Good variable names regexes, separated by a comma. If names match any regex, 198 | # they will always be accepted 199 | good-names-rgxs= 200 | 201 | # Include a hint for the correct naming format with invalid-name. 202 | include-naming-hint=no 203 | 204 | # Naming style matching correct inline iteration names. 205 | inlinevar-naming-style=any 206 | 207 | # Regular expression matching correct inline iteration names. Overrides 208 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 209 | # with the set naming style. 210 | #inlinevar-rgx= 211 | 212 | # Naming style matching correct method names. 213 | method-naming-style=snake_case 214 | 215 | # Regular expression matching correct method names. Overrides method-naming- 216 | # style. If left empty, method names will be checked with the set naming style. 217 | #method-rgx= 218 | 219 | # Naming style matching correct module names. 220 | module-naming-style=snake_case 221 | 222 | # Regular expression matching correct module names. Overrides module-naming- 223 | # style. If left empty, module names will be checked with the set naming style. 224 | #module-rgx= 225 | 226 | # Colon-delimited sets of names that determine each other's naming style when 227 | # the name regexes allow several styles. 228 | name-group= 229 | 230 | # Regular expression which should only match function or class names that do 231 | # not require a docstring. 232 | no-docstring-rgx=^_ 233 | 234 | # List of decorators that produce properties, such as abc.abstractproperty. Add 235 | # to this list to register other decorators that produce valid properties. 236 | # These decorators are taken in consideration only for invalid-name. 237 | property-classes=abc.abstractproperty 238 | 239 | # Regular expression matching correct type alias names. If left empty, type 240 | # alias names will be checked with the set naming style. 241 | #typealias-rgx= 242 | 243 | # Regular expression matching correct type variable names. If left empty, type 244 | # variable names will be checked with the set naming style. 245 | #typevar-rgx= 246 | 247 | # Naming style matching correct variable names. 248 | variable-naming-style=snake_case 249 | 250 | # Regular expression matching correct variable names. Overrides variable- 251 | # naming-style. If left empty, variable names will be checked with the set 252 | # naming style. 253 | #variable-rgx= 254 | 255 | 256 | [CLASSES] 257 | 258 | # Warn about protected attribute access inside special methods 259 | check-protected-access-in-special-methods=no 260 | 261 | # List of method names used to declare (i.e. assign) instance attributes. 262 | defining-attr-methods=__init__, 263 | __new__, 264 | setUp, 265 | asyncSetUp, 266 | __post_init__ 267 | 268 | # List of member names, which should be excluded from the protected access 269 | # warning. 270 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 271 | 272 | # List of valid names for the first argument in a class method. 273 | valid-classmethod-first-arg=cls 274 | 275 | # List of valid names for the first argument in a metaclass class method. 276 | valid-metaclass-classmethod-first-arg=mcs 277 | 278 | 279 | [DESIGN] 280 | 281 | # List of regular expressions of class ancestor names to ignore when counting 282 | # public methods (see R0903) 283 | exclude-too-few-public-methods= 284 | 285 | # List of qualified class names to ignore when counting class parents (see 286 | # R0901) 287 | ignored-parents= 288 | 289 | # Maximum number of arguments for function / method. 290 | max-args=5 291 | 292 | # Maximum number of attributes for a class (see R0902). 293 | max-attributes=7 294 | 295 | # Maximum number of boolean expressions in an if statement (see R0916). 296 | max-bool-expr=5 297 | 298 | # Maximum number of branch for function / method body. 299 | max-branches=12 300 | 301 | # Maximum number of locals for function / method body. 302 | max-locals=15 303 | 304 | # Maximum number of parents for a class (see R0901). 305 | max-parents=7 306 | 307 | # Maximum number of public methods for a class (see R0904). 308 | max-public-methods=20 309 | 310 | # Maximum number of return / yield for function / method body. 311 | max-returns=6 312 | 313 | # Maximum number of statements in function / method body. 314 | max-statements=50 315 | 316 | # Minimum number of public methods for a class (see R0903). 317 | min-public-methods=2 318 | 319 | 320 | [EXCEPTIONS] 321 | 322 | # Exceptions that will emit a warning when caught. 323 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 324 | 325 | 326 | [FORMAT] 327 | 328 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 329 | expected-line-ending-format= 330 | 331 | # Regexp for a line that is allowed to be longer than the limit. 332 | ignore-long-lines=^\s*(# )??$ 333 | 334 | # Number of spaces of indent required inside a hanging or continued line. 335 | indent-after-paren=4 336 | 337 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 338 | # tab). 339 | indent-string=' ' 340 | 341 | # Maximum number of characters on a single line. 342 | max-line-length=100 343 | 344 | # Maximum number of lines in a module. 345 | max-module-lines=1000 346 | 347 | # Allow the body of a class to be on the same line as the declaration if body 348 | # contains single statement. 349 | single-line-class-stmt=no 350 | 351 | # Allow the body of an if to be on the same line as the test if there is no 352 | # else. 353 | single-line-if-stmt=no 354 | 355 | 356 | [IMPORTS] 357 | 358 | # List of modules that can be imported at any level, not just the top level 359 | # one. 360 | allow-any-import-level= 361 | 362 | # Allow explicit reexports by alias from a package __init__. 363 | allow-reexport-from-package=no 364 | 365 | # Allow wildcard imports from modules that define __all__. 366 | allow-wildcard-with-all=no 367 | 368 | # Deprecated modules which should not be used, separated by a comma. 369 | deprecated-modules= 370 | 371 | # Output a graph (.gv or any supported image format) of external dependencies 372 | # to the given file (report RP0402 must not be disabled). 373 | ext-import-graph= 374 | 375 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 376 | # external) dependencies to the given file (report RP0402 must not be 377 | # disabled). 378 | import-graph= 379 | 380 | # Output a graph (.gv or any supported image format) of internal dependencies 381 | # to the given file (report RP0402 must not be disabled). 382 | int-import-graph= 383 | 384 | # Force import order to recognize a module as part of the standard 385 | # compatibility libraries. 386 | known-standard-library= 387 | 388 | # Force import order to recognize a module as part of a third party library. 389 | known-third-party=enchant 390 | 391 | # Couples of modules and preferred modules, separated by a comma. 392 | preferred-modules= 393 | 394 | 395 | [LOGGING] 396 | 397 | # The type of string formatting that logging methods do. `old` means using % 398 | # formatting, `new` is for `{}` formatting. 399 | logging-format-style=old 400 | 401 | # Logging modules to check that the string format arguments are in logging 402 | # function parameter format. 403 | logging-modules=logging 404 | 405 | 406 | [MESSAGES CONTROL] 407 | 408 | # Only show warnings with the listed confidence levels. Leave empty to show 409 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 410 | # UNDEFINED. 411 | confidence=HIGH, 412 | CONTROL_FLOW, 413 | INFERENCE, 414 | INFERENCE_FAILURE, 415 | UNDEFINED 416 | 417 | # Disable the message, report, category or checker with the given id(s). You 418 | # can either give multiple identifiers separated by comma (,) or put this 419 | # option multiple times (only on the command line, not in the configuration 420 | # file where it should appear only once). You can also use "--disable=all" to 421 | # disable everything first and then re-enable specific checks. For example, if 422 | # you want to run only the similarities checker, you can use "--disable=all 423 | # --enable=similarities". If you want to run only the classes checker, but have 424 | # no Warning level messages displayed, use "--disable=all --enable=classes 425 | # --disable=W". 426 | disable=raw-checker-failed, 427 | bad-inline-option, 428 | locally-disabled, 429 | file-ignored, 430 | suppressed-message, 431 | useless-suppression, 432 | deprecated-pragma, 433 | use-symbolic-message-instead, 434 | missing-module-docstring, 435 | missing-class-docstring, 436 | missing-function-docstring, 437 | invalid-name, 438 | import-outside-toplevel, 439 | too-few-public-methods, 440 | too-many-ancestors, 441 | fixme, 442 | attribute-defined-outside-init, 443 | line-too-long, 444 | django-not-configured 445 | 446 | # Enable the message, report, category or checker with the given id(s). You can 447 | # either give multiple identifier separated by comma (,) or put this option 448 | # multiple time (only on the command line, not in the configuration file where 449 | # it should appear only once). See also the "--disable" option for examples. 450 | enable=c-extension-no-member 451 | 452 | 453 | [METHOD_ARGS] 454 | 455 | # List of qualified names (i.e., library.method) which require a timeout 456 | # parameter e.g. 'requests.api.get,requests.api.post' 457 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 458 | 459 | 460 | [MISCELLANEOUS] 461 | 462 | # List of note tags to take in consideration, separated by a comma. 463 | notes=FIXME, 464 | XXX, 465 | TODO 466 | 467 | # Regular expression of note tags to take in consideration. 468 | notes-rgx= 469 | 470 | 471 | [REFACTORING] 472 | 473 | # Maximum number of nested blocks for function / method body 474 | max-nested-blocks=5 475 | 476 | # Complete name of functions that never returns. When checking for 477 | # inconsistent-return-statements if a never returning function is called then 478 | # it will be considered as an explicit return statement and no message will be 479 | # printed. 480 | never-returning-functions=sys.exit,argparse.parse_error 481 | 482 | 483 | [REPORTS] 484 | 485 | # Python expression which should return a score less than or equal to 10. You 486 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 487 | # 'convention', and 'info' which contain the number of messages in each 488 | # category, as well as 'statement' which is the total number of statements 489 | # analyzed. This score is used by the global evaluation report (RP0004). 490 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 491 | 492 | # Template used to display messages. This is a python new-style format string 493 | # used to format the message information. See doc for all details. 494 | msg-template= 495 | 496 | # Set the output format. Available formats are text, parseable, colorized, json 497 | # and msvs (visual studio). You can also give a reporter class, e.g. 498 | # mypackage.mymodule.MyReporterClass. 499 | #output-format= 500 | 501 | # Tells whether to display a full report or only the messages. 502 | reports=no 503 | 504 | # Activate the evaluation score. 505 | score=yes 506 | 507 | 508 | [SIMILARITIES] 509 | 510 | # Comments are removed from the similarity computation 511 | ignore-comments=yes 512 | 513 | # Docstrings are removed from the similarity computation 514 | ignore-docstrings=yes 515 | 516 | # Imports are removed from the similarity computation 517 | ignore-imports=yes 518 | 519 | # Signatures are removed from the similarity computation 520 | ignore-signatures=yes 521 | 522 | # Minimum lines number of a similarity. 523 | min-similarity-lines=4 524 | 525 | 526 | [SPELLING] 527 | 528 | # Limits count of emitted suggestions for spelling mistakes. 529 | max-spelling-suggestions=4 530 | 531 | # Spelling dictionary name. No available dictionaries : You need to install 532 | # both the python package and the system dependency for enchant to work.. 533 | spelling-dict= 534 | 535 | # List of comma separated words that should be considered directives if they 536 | # appear at the beginning of a comment and should not be checked. 537 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 538 | 539 | # List of comma separated words that should not be checked. 540 | spelling-ignore-words= 541 | 542 | # A path to a file that contains the private dictionary; one word per line. 543 | spelling-private-dict-file= 544 | 545 | # Tells whether to store unknown words to the private dictionary (see the 546 | # --spelling-private-dict-file option) instead of raising a message. 547 | spelling-store-unknown-words=no 548 | 549 | 550 | [STRING] 551 | 552 | # This flag controls whether inconsistent-quotes generates a warning when the 553 | # character used as a quote delimiter is used inconsistently within a module. 554 | check-quote-consistency=no 555 | 556 | # This flag controls whether the implicit-str-concat should generate a warning 557 | # on implicit string concatenation in sequences defined over several lines. 558 | check-str-concat-over-line-jumps=no 559 | 560 | 561 | [TYPECHECK] 562 | 563 | # List of decorators that produce context managers, such as 564 | # contextlib.contextmanager. Add to this list to register other decorators that 565 | # produce valid context managers. 566 | contextmanager-decorators=contextlib.contextmanager 567 | 568 | # List of members which are set dynamically and missed by pylint inference 569 | # system, and so shouldn't trigger E1101 when accessed. Python regular 570 | # expressions are accepted. 571 | generated-members= 572 | 573 | # Tells whether to warn about missing members when the owner of the attribute 574 | # is inferred to be None. 575 | ignore-none=yes 576 | 577 | # This flag controls whether pylint should warn about no-member and similar 578 | # checks whenever an opaque object is returned when inferring. The inference 579 | # can return multiple potential results while evaluating a Python object, but 580 | # some branches might not be evaluated, which results in partial inference. In 581 | # that case, it might be useful to still emit no-member and other checks for 582 | # the rest of the inferred objects. 583 | ignore-on-opaque-inference=yes 584 | 585 | # List of symbolic message names to ignore for Mixin members. 586 | ignored-checks-for-mixins=no-member, 587 | not-async-context-manager, 588 | not-context-manager, 589 | attribute-defined-outside-init 590 | 591 | # List of class names for which member attributes should not be checked (useful 592 | # for classes with dynamically set attributes). This supports the use of 593 | # qualified names. 594 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 595 | 596 | # Show a hint with possible names when a member name was not found. The aspect 597 | # of finding the hint is based on edit distance. 598 | missing-member-hint=yes 599 | 600 | # The minimum edit distance a name should have in order to be considered a 601 | # similar match for a missing member name. 602 | missing-member-hint-distance=1 603 | 604 | # The total number of similar names that should be taken in consideration when 605 | # showing a hint for a missing member. 606 | missing-member-max-choices=1 607 | 608 | # Regex pattern to define which classes are considered mixins. 609 | mixin-class-rgx=.*[Mm]ixin 610 | 611 | # List of decorators that change the signature of a decorated function. 612 | signature-mutators= 613 | 614 | 615 | [VARIABLES] 616 | 617 | # List of additional names supposed to be defined in builtins. Remember that 618 | # you should avoid defining new builtins when possible. 619 | additional-builtins= 620 | 621 | # Tells whether unused global variables should be treated as a violation. 622 | allow-global-unused-variables=yes 623 | 624 | # List of names allowed to shadow builtins 625 | allowed-redefined-builtins= 626 | 627 | # List of strings which can identify a callback function by name. A callback 628 | # name must start or end with one of those strings. 629 | callbacks=cb_, 630 | _cb 631 | 632 | # A regular expression matching the name of dummy variables (i.e. expected to 633 | # not be used). 634 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 635 | 636 | # Argument names that match this expression will be ignored. 637 | ignored-argument-names=_.*|^ignored_|^unused_ 638 | 639 | # Tells whether we should check for unused import in __init__ files. 640 | init-import=no 641 | 642 | # List of qualified module names which can have objects that can redefine 643 | # builtins. 644 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 645 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.10" 13 | jobs: 14 | post_build: 15 | - ls 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/source/conf.py 20 | 21 | # Optionally build your docs in additional formats such as PDF and ePub 22 | # formats: 23 | # - pdf 24 | # - epub 25 | 26 | # Optional but recommended, declare the Python requirements required 27 | # to build your documentation 28 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 29 | python: 30 | install: 31 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Leif Kjos 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://pypi.org/project/django-custom-admin-pages/) 2 |  3 |  4 | [](https://github.com/lekjos/django-custom-admin-pages/actions/workflows/build_and_test.yml) 5 | [](https://django-custom-admin-pages.readthedocs.io/en/latest/?badge=latest) 6 | [](https://codecov.io/gh/lekjos/django-custom-admin-pages) 7 |  8 | 9 | 10 | # Django Custom Admin Pages 11 | A django app that lets you add standard class-based views to the django admin index and navigation. Create a view, register it like you would a ModelAdmin, and it appears in the Django Admin Nav. 12 | 13 |  14 | 15 | Check out the [full documentation](https://django-custom-admin-pages.readthedocs.io) for more in-depth information. 16 | 17 | 18 | ## Quick Start 19 | 20 | 1. Install the app from pypi `pip install django_custom_admin_pages` 21 | 2. Remove `django.contrib.admin` from your installed apps 22 | 3. In your django settings file add the following lines to your `INSTALLED_APPS``: 23 | 24 | ```python 25 | INSTALLED_APPS = [ 26 | # "django.contrib.admin", #REMOVE THIS LINE 27 | # ... 28 | "django_custom_admin_pages", 29 | "django_custom_admin_pages.admin.CustomAdminConfig" 30 | # ... 31 | ] 32 | ``` 33 | 34 | ## Usage 35 | 36 | To create a new custom admin view: 37 | 38 | 1. Create a class-based view in `django_custom_admin_pages.views` which inherits from `custom_admin.views.admin_base_view.AdminBaseView`. 39 | 2. Set the view class attribute `view_name` to whatever name you want displayed in the admin index. 40 | 3. Register the view similar to how you would register a ModelAdmin using a custom admin function: `admin.site.register_view(YourView)`. 41 | 4. Use the template `django_custom_admin_pages.templates.base_custom_admin.html` as a sample for how to extend the admin templates so that your view has the admin nav. 42 | 43 | 44 | Also see `test_proj.test_app.views.example_view.py` 45 | 46 | _Example:_ 47 | 48 | ```python 49 | ## in django_custom_admin_pages.views.your_special_view.py 50 | from django.contrib import admin 51 | from django.views.generic import TemplateView 52 | from django_custom_admin_pages.views.admin_base_view import AdminBaseView 53 | 54 | class YourCustomView(AdminBaseView, TemplateView): 55 | view_name="My Super Special View" 56 | template_name="my_template.html" 57 | route_name="some-custom-route-name" # if omitted defaults to snake_case of view_name 58 | app_label="my_app" # if omitted defaults to "django_custom_admin_pages". Must match app in settings 59 | 60 | # always call super() on get_context_data and use it to start your context dict. 61 | # the context required to render admin nav-bar is included here. 62 | def get_context_data(self, *args, **kwargs): 63 | context:dict = super().get_context_data(*args, **kwargs) 64 | # add your context ... 65 | return context 66 | 67 | admin.site.register_view(YourCustomView) 68 | ``` 69 | 70 | Your template should extend `admin/base.html` or `base_custom_admin.html` template: 71 | ```html 72 | 73 | {% extends 'base_custom_admin.html' with title="your page title" %} 74 | {% block content %} 75 |
Override the content block with your page content.
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /django_custom_admin_pages/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekjos/django-custom-admin-pages/e26a3b59a7526cee8ea80309cfe8bae95ae0cdb4/django_custom_admin_pages/tests/__init__.py -------------------------------------------------------------------------------- /django_custom_admin_pages/tests/test_custom_admin_pages.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib import reload 3 | 4 | from django.conf import settings 5 | from django.contrib import admin 6 | from django.contrib.auth import get_user_model 7 | from django.contrib.auth.models import AbstractUser, Permission 8 | from django.contrib.contenttypes.models import ContentType 9 | from django.core.exceptions import ImproperlyConfigured 10 | from django.shortcuts import render 11 | from django.test import RequestFactory 12 | from django.urls import clear_url_caches, reverse 13 | from django.utils.text import get_valid_filename, slugify 14 | from django.views.generic import TemplateView 15 | 16 | import pytest 17 | 18 | from ..exceptions import CustomAdminImportException 19 | from ..views.admin_base_view import AdminBaseView 20 | 21 | User: AbstractUser = get_user_model() 22 | 23 | ADMIN_BASE_URL = reverse("admin:index") 24 | django_custom_admin_pages_URL = ( 25 | f"{ADMIN_BASE_URL}{settings.CUSTOM_ADMIN_DEFAULT_APP_LABEL}/" 26 | ) 27 | 28 | 29 | def reload_urlconf(urlconf=None): 30 | """ 31 | reloads urlconf, or specific urls.py passed in via urlconf arg. 32 | """ 33 | urlconf = settings.ROOT_URLCONF 34 | if urlconf in sys.modules: 35 | clear_url_caches() 36 | reload(sys.modules[urlconf]) 37 | 38 | 39 | class AnExampleView(AdminBaseView, TemplateView): 40 | view_name = "Test Name" 41 | route_name = "test_route" 42 | template_name = "base_custom_admin.html" 43 | 44 | 45 | class AnExampleAppView(AdminBaseView, TemplateView): 46 | view_name = "Test App View" 47 | app_label = "test_app" 48 | route_name = "test_app_route" 49 | template_name = "base_custom_admin.html" 50 | permission_required = "test_app.test_perm" 51 | 52 | 53 | class BadAppNameView(AnExampleView): 54 | app_label = "fake_app" 55 | 56 | 57 | class AnotherExampleView(AdminBaseView, TemplateView): 58 | view_name = "Test Name" 59 | route_name = "test_route1" 60 | template_name = "base_custom_admin.html" 61 | 62 | 63 | class NotInheritedView(TemplateView): 64 | view_name = "Test Name" 65 | route_name = "test_route" 66 | template_name = "base_custom_admin.html" 67 | 68 | 69 | def not_a_class_based_view(request): 70 | return render(request, "base_custom_admin.html", context=dict()) 71 | 72 | 73 | class NoViewName(AdminBaseView, TemplateView): 74 | route_name = "test_route2" 75 | template_name = "base_custom_admin.html" 76 | 77 | 78 | class NoRouteName(AdminBaseView, TemplateView): 79 | view_name = "Test Name" 80 | template_name = "base_custom_admin.html" 81 | 82 | 83 | @pytest.fixture 84 | def superuser(): 85 | return User.objects.create( 86 | username="Julian", 87 | password="JulianTheWizard", 88 | is_staff=True, 89 | is_active=True, 90 | is_superuser=True, 91 | ) 92 | 93 | 94 | class TestRegistration: 95 | """ 96 | Test registering a view 97 | """ 98 | 99 | def test_it_raises_when_not_class_based(self): 100 | with pytest.raises(ImproperlyConfigured): 101 | admin.site.register_view(not_a_class_based_view) 102 | 103 | def test_it_raises_when_no_view_name(self): 104 | with pytest.raises(ImproperlyConfigured): 105 | admin.site.register_view(NoViewName) 106 | 107 | def test_it_doesnt_raise_when_no_route_name(self): 108 | admin.site.register_view(NoRouteName) 109 | assert ( 110 | NoRouteName.route_name == get_valid_filename(NoRouteName.view_name).lower() 111 | ) 112 | assert NoRouteName.route_path == slugify(NoRouteName.view_name).lower() 113 | 114 | def test_it_raises_when_not_subclassed(self): 115 | with pytest.raises(ImproperlyConfigured): 116 | admin.site.register_view(NotInheritedView) 117 | 118 | def test_register_twice(self): 119 | with pytest.raises(admin.sites.AlreadyRegistered): 120 | admin.site.register_view([AnExampleView, AnExampleView]) 121 | admin.site.unregister_view(AnExampleView) 122 | 123 | def test_unregister_unregistered_raises_error(self): 124 | with pytest.raises(admin.sites.NotRegistered): 125 | admin.site.unregister_view(AnExampleView) 126 | 127 | def test_register_multiple(self): 128 | admin.site.register_view([AnExampleView, AnotherExampleView]) 129 | admin.site.unregister_view([AnExampleView, AnotherExampleView]) 130 | 131 | @pytest.mark.django_db 132 | def test_register_late_raises(self, superuser): 133 | admin.site.register_view(AnExampleView) 134 | 135 | request_factory = RequestFactory() 136 | 137 | with pytest.raises( 138 | CustomAdminImportException, 139 | match="Cannot find CustomAdminView: Test Name. This is most likely because the root url conf was loaded before the view was registered. Try importing the view at the top of your root url conf or placing the registration above url_patterns.", 140 | ): 141 | request = request_factory.get(reverse("admin:index")) 142 | request.user = superuser 143 | admin.site.get_app_list(request) 144 | 145 | admin.site.unregister_view(AnExampleView) 146 | 147 | def test_register_bad_app_name(self): 148 | with pytest.raises( 149 | ImproperlyConfigured, 150 | match="Your view Test Name has an invalid app_label: fake_app. App label must be in settings.INSTALLED_APPS", 151 | ): 152 | admin.site.register_view(BadAppNameView) 153 | 154 | 155 | @pytest.fixture 156 | def view(): 157 | admin.site.register_view(AnExampleView) 158 | reload_urlconf() 159 | yield 160 | admin.site.unregister_view(AnExampleView) 161 | 162 | 163 | @pytest.fixture 164 | def view_to_register(): 165 | return AnExampleAppView 166 | 167 | 168 | @pytest.fixture 169 | def app_view(view_to_register): 170 | admin.site.register_view(view_to_register) 171 | reload_urlconf() 172 | yield 173 | admin.site.unregister_view(view_to_register) 174 | 175 | 176 | class TestPageRendering: 177 | @pytest.fixture 178 | def super_client(self, client, superuser): 179 | client.force_login(superuser) 180 | assert superuser.is_staff 181 | assert superuser.is_active 182 | assert superuser.is_superuser 183 | return client 184 | 185 | @pytest.mark.django_db 186 | def test_admin_index_newly_registered_view(self, view, super_client): 187 | """ 188 | Verify that view is in admin custom views 189 | """ 190 | 191 | # add route in runtime and reload urlconf 192 | 193 | r = super_client.get(reverse("admin:test_route")) 194 | assert r.status_code == 200 195 | 196 | django_custom_admin_pages_dict: dict = list( 197 | filter( 198 | lambda x: x["app_label"] == "django_custom_admin_pages", 199 | r.context["app_list"], 200 | ) 201 | )[0] 202 | test_view: dict = list( 203 | filter( 204 | lambda x: x["admin_url"] == f"{django_custom_admin_pages_URL}test-name", 205 | django_custom_admin_pages_dict["models"], 206 | ) 207 | )[0] 208 | 209 | assert ( 210 | django_custom_admin_pages_dict["app_url"] == django_custom_admin_pages_URL 211 | ) 212 | assert django_custom_admin_pages_dict["name"] == "Custom Admin Pages" 213 | assert ( 214 | django_custom_admin_pages_dict["app_label"] 215 | == settings.CUSTOM_ADMIN_DEFAULT_APP_LABEL 216 | ) 217 | assert test_view["name"] == "Test Name" == test_view["object_name"] 218 | assert test_view["view_only"] 219 | 220 | @pytest.mark.django_db 221 | def test_admin_index_newly_registered_app_view(self, app_view, super_client): 222 | """ 223 | Verify that view is in admin custom views 224 | """ 225 | 226 | # add route in runtime and reload urlconf 227 | 228 | r = super_client.get(reverse("admin:test_app_route")) 229 | assert r.status_code == 200 230 | 231 | app_dict: dict = [ 232 | x for x in r.context["app_list"] if x["app_label"] == "test_app" 233 | ][0] 234 | 235 | test_view: dict = [ 236 | x 237 | for x in app_dict["models"] 238 | if x["admin_url"] == "/admin/test_app/test-app-view" 239 | ][0] 240 | 241 | assert app_dict["app_url"] == "/admin/test_app/" 242 | assert app_dict["name"] == "Test_App" 243 | assert app_dict["app_label"] == "test_app" 244 | assert test_view["name"] == "Test App View" == test_view["object_name"] 245 | assert test_view["view_only"] 246 | 247 | 248 | class TestGetAppList: 249 | class TestCaseStandardRegistration: 250 | """test an app registered in INSTALLED_APPS with just app name""" 251 | 252 | @pytest.mark.django_db 253 | def test_get_app_list(self, superuser, app_view): 254 | request_factory = RequestFactory() 255 | request = request_factory.get(reverse("admin:index")) 256 | request.user = superuser 257 | 258 | app_list = admin.site.get_app_list(request) 259 | test_app = [x for x in app_list if x["name"] == "Test_App"][0] 260 | assert ( 261 | len([x for x in test_app["models"] if x["name"] == "Test App View"]) 262 | == 1 263 | ) 264 | 265 | class TestCaseFullRegistration: 266 | """test an app registered in INSTALLED_APPS with app_name.apps.appconfig""" 267 | 268 | @pytest.mark.django_db 269 | def test_get_app_list(self, superuser): 270 | request_factory = RequestFactory() 271 | request = request_factory.get(reverse("admin:index")) 272 | request.user = superuser 273 | 274 | app_list = admin.site.get_app_list(request) 275 | test_app = [x for x in app_list if x["name"] == "Another_Test_App"][0] 276 | assert ( 277 | len( 278 | [ 279 | x 280 | for x in test_app["models"] 281 | if x["name"] == "Another Example View" 282 | ] 283 | ) 284 | == 1 285 | ) 286 | 287 | 288 | class TestPermissions: 289 | @pytest.fixture 290 | def test_app_ct(self): 291 | return ContentType.objects.get(app_label="test_app", model="somemodel") 292 | 293 | @pytest.fixture 294 | def permission(self, test_app_ct): 295 | return Permission.objects.create( 296 | name="Test Perm", codename="test_perm", content_type=test_app_ct 297 | ) 298 | 299 | @pytest.fixture 300 | def active(self): 301 | return True 302 | 303 | @pytest.fixture 304 | def staff(self): 305 | return True 306 | 307 | @pytest.fixture 308 | def user(self, permission, active, staff): 309 | u = User.objects.create( 310 | username="Bill", 311 | password="Billspw", 312 | is_staff=staff, 313 | is_active=active, 314 | ) 315 | if permission: 316 | u.user_permissions.add(permission) 317 | return u 318 | 319 | class TestCaseRequiredPermission: 320 | @pytest.mark.django_db 321 | def test_it_shows_if_user_has_permission(self, user, app_view): 322 | request_factory = RequestFactory() 323 | request = request_factory.get(reverse("admin:index")) 324 | request.user = user 325 | 326 | app_list = admin.site.get_app_list(request) 327 | test_app = [x for x in app_list if x["name"] == "Test_App"][0] 328 | assert ( 329 | len([x for x in test_app["models"] if x["name"] == "Test App View"]) 330 | == 1 331 | ) 332 | 333 | class TestCaseNotRequiredPermission: 334 | @pytest.fixture 335 | def view_to_register(self): 336 | return AnExampleView 337 | 338 | @pytest.fixture 339 | def permission(self): 340 | return None 341 | 342 | @pytest.mark.django_db 343 | def test_it_shows_to_staff_with_no_permission(self, user, app_view): 344 | user.user_permissions.clear() 345 | request_factory = RequestFactory() 346 | request = request_factory.get(reverse("admin:index")) 347 | request.user = user 348 | 349 | app_list = admin.site.get_app_list(request) 350 | test_app = [x for x in app_list if x["name"] == "Custom Admin Pages"][0] 351 | assert len([x for x in test_app["models"] if x["name"] == "Test Name"]) == 1 352 | 353 | class TestCaseMissingRequiredPermission: 354 | @pytest.fixture 355 | def permission(self): 356 | return None 357 | 358 | @pytest.mark.django_db 359 | def test_it_doesnt_show_if_user_has_no_permission(self, user, app_view): 360 | user.user_permissions.clear() 361 | request_factory = RequestFactory() 362 | request = request_factory.get(reverse("admin:index")) 363 | request.user = user 364 | 365 | app_list = admin.site.get_app_list(request) 366 | assert len([x for x in app_list if x["name"] == "Test_App"]) == 0 367 | 368 | class TestCaseInactiveUser: 369 | @pytest.fixture 370 | def active(self): 371 | return False 372 | 373 | @pytest.mark.django_db 374 | def test_it_denies_inactive_user(self, user, app_view): 375 | request_factory = RequestFactory() 376 | request = request_factory.get(reverse("admin:index")) 377 | request.user = user 378 | 379 | app_list = admin.site.get_app_list(request) 380 | assert len([x for x in app_list if x["name"] == "Test_App"]) == 0 381 | 382 | class TestCaseNotStaff: 383 | @pytest.fixture 384 | def staff(self): 385 | return False 386 | 387 | @pytest.mark.django_db 388 | def test_it_denies_inactive_user(self, user, app_view): 389 | request_factory = RequestFactory() 390 | request = request_factory.get(reverse("admin:index")) 391 | request.user = user 392 | 393 | app_list = admin.site.get_app_list(request) 394 | assert len([x for x in app_list if x["name"] == "Test_App"]) == 0 395 | -------------------------------------------------------------------------------- /django_custom_admin_pages/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from test_proj.another_test_app.views import ( 5 | AnotherExampleAdminView, # required for view to register 6 | ) 7 | 8 | urlpatterns = [path("admin/", admin.site.urls)] 9 | -------------------------------------------------------------------------------- /django_custom_admin_pages/urls.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django.conf import settings 4 | from django.urls import path 5 | from django.utils.text import get_valid_filename, slugify 6 | 7 | if TYPE_CHECKING: 8 | from .views import AdminBaseView 9 | 10 | 11 | urlpatterns = [] 12 | 13 | 14 | def add_view_to_conf(view: "AdminBaseView"): 15 | global urlpatterns # pylint: disable=global-statement 16 | 17 | if not view.app_label: 18 | view.app_label = settings.CUSTOM_ADMIN_DEFAULT_APP_LABEL 19 | if not view.route_path: 20 | view.route_path = slugify(view.view_name).lower() 21 | 22 | if not view.route_name: 23 | view.route_name = get_valid_filename(view.view_name).lower() 24 | 25 | urlpatterns += [ 26 | path( 27 | f"{view.app_label}/{view.route_path}", 28 | view.as_view(), 29 | name=view.route_name, 30 | ) 31 | ] 32 | -------------------------------------------------------------------------------- /django_custom_admin_pages/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .admin_base_view import AdminBaseView 2 | -------------------------------------------------------------------------------- /django_custom_admin_pages/views/admin_base_view.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Iterable, Optional 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth.mixins import PermissionRequiredMixin 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.utils.decorators import method_decorator 7 | from django.views import View 8 | from django.views.decorators.cache import never_cache 9 | 10 | if TYPE_CHECKING: 11 | from django.contrib.auth.models import AbstractBaseUser 12 | 13 | 14 | @method_decorator(never_cache, name="dispatch") 15 | class AdminBaseView(PermissionRequiredMixin, View): 16 | """ 17 | Base class for custom admin views 18 | 19 | 20 | :cvar view_name: 21 | Display name for view in admin menu 22 | 23 | :type: str or none 24 | :default: none 25 | 26 | :cvar route_name: 27 | The name of the path to be created, defaults to no route name 28 | 29 | :type: str or none 30 | :default: none 31 | 32 | :cvar route_path: 33 | The slug for the path to be created, defaults to slugified view name 34 | 35 | :type: str or none 36 | :default: slug-of-view-name 37 | 38 | :cvar permission_required: 39 | iterable of permissions codenames required to use view. example format: app_label.codename 40 | 41 | :type: tuple[str] 42 | :default: () 43 | 44 | :cvar app_label: 45 | The app label that the view will appear under. Must match an app in settings.INSTALLED_APPS. Defaults 46 | to settings.CUSTOM_ADMIN_DEFAULT_APP_LABEL 47 | 48 | :type: [str] or none 49 | :default: none 50 | """ 51 | 52 | view_name: str = None # Display name for view in admin menu 53 | route_name: Optional[ 54 | str 55 | ] = None # The name of the path to be created, defaults to no name 56 | route_path: Optional[ 57 | str 58 | ] = None # The slug for the path to be created, defaults to view name 59 | permission_required = () 60 | app_label: Optional[str] = None # Must match app label in settings or be None 61 | 62 | def has_permission(self): 63 | return self.user_has_permission(self.request.user) 64 | 65 | def user_has_permission(self, user: "AbstractBaseUser") -> bool: 66 | """ 67 | Used to check permission without instance. 68 | """ 69 | if not user.is_active: 70 | return False 71 | 72 | if user.is_superuser: 73 | return True 74 | 75 | if user.is_staff: 76 | if perms := self.get_permission_required(): 77 | return user.has_perms(perms) 78 | return True 79 | 80 | return False 81 | 82 | def get_permission_required(self): 83 | if not hasattr(self, "permission_required"): 84 | cls_name = self.__class__.__name__ 85 | message = f"{cls_name} is missing the permission_required attribute. Define {cls_name}.permission_required, or override {cls_name}.get_permission_required()." 86 | raise ImproperlyConfigured(message) 87 | if isinstance(self.permission_required, str): 88 | perms = (self.permission_required,) 89 | elif isinstance(self.permission_required, Iterable): 90 | perms = self.permission_required 91 | else: 92 | raise ValueError( 93 | f"{self.__class__.__name__}.permission_required must be a string or iterable" 94 | ) 95 | return perms 96 | 97 | def get_context_data(self, *args, **kwargs): 98 | """ 99 | adds admin site context 100 | """ 101 | admin_site = admin.site 102 | self.request.name = admin_site.name 103 | context: dict = admin_site.each_context(self.request) 104 | if hasattr(super(), "get_context_data"): 105 | context.update(super().get_context_data(*args, **kwargs)) 106 | return context 107 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.7.2 ; python_version >= "3.9" and python_version < "4.0" 2 | django==4.2.11 ; python_version >= "3.9" and python_version < "4.0" 3 | pytz==2023.3 ; python_version >= "3.9" and python_version < "4.0" 4 | sqlparse==0.4.4 ; python_version >= "3.9" and python_version < "4.0" 5 | typing-extensions==4.7.1 ; python_version >= "3.9" and python_version < "3.11" 6 | sphinx-rtd-theme==1.3.0 -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | ========================= 3 | 4 | .. automodule:: django_custom_admin_pages.admin 5 | :members: CustomAdminSite, CustomAdminConfig 6 | :undoc-members: ViewRegister 7 | :show-inheritance: 8 | 9 | .. automodule:: django_custom_admin_pages.views.admin_base_view 10 | :members: 11 | :show-inheritance: -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | top_level_dir = Path(__file__).resolve().parent.parent.parent 7 | module_dir = os.path.join(top_level_dir, "django_custom_admin_pages") 8 | 9 | sys.path.append(str(module_dir)) 10 | sys.path.append(str(top_level_dir)) 11 | 12 | from django_custom_admin_pages.boot_django import boot_django 13 | 14 | boot_django() 15 | 16 | 17 | # -- Project information 18 | 19 | project = "Django Custom Admin Pages" 20 | copyright = "" 21 | author = "lekjos" 22 | 23 | release = "1.1.1" 24 | version = "1.1.1" 25 | 26 | # -- General configuration 27 | 28 | extensions = [ 29 | "sphinx.ext.duration", 30 | "sphinx.ext.doctest", 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.autosummary", 33 | "sphinx.ext.intersphinx", 34 | ] 35 | 36 | intersphinx_mapping = { 37 | "python": ("https://docs.python.org/3/", None), 38 | "sphinx": ("https://www.sphinx-doc.org/en/master/", None), 39 | } 40 | intersphinx_disabled_domains = ["std"] 41 | 42 | templates_path = ["_templates"] 43 | 44 | # -- Options for HTML output 45 | 46 | html_theme = "sphinx_rtd_theme" 47 | 48 | # -- Options for EPUB output 49 | epub_show_urls = "footnote" 50 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Django Custom Admin Pages 2 | =================================== 3 | 4 | **Django Custom Admin Pages** A django app that lets you add standard class-based views 5 | to the django admin index and navigation. Create a view, register it like you would a 6 | ModelAdmin, and it appears in the Django Admin Nav. 7 | 8 | 9 | .. image:: static/example_view.png 10 | :alt: Example View 11 | 12 | 13 | Check out the :doc:`usage` section for further information, including 14 | how to :ref:`installation` the project. 15 | 16 | Contents 17 | -------- 18 | 19 | .. toctree:: 20 | 21 | usage 22 | api 23 | -------------------------------------------------------------------------------- /docs/source/static/example_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekjos/django-custom-admin-pages/e26a3b59a7526cee8ea80309cfe8bae95ae0cdb4/docs/source/static/example_view.png -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | .. _installation: 5 | 6 | Installation 7 | ------------ 8 | 9 | 1. Install the app from pypi :py:class:`pip install django_custom_admin_pages` 10 | 2. Remove :py:class:`django.contrib.admin` from your installed apps 11 | 3. In your django settings file add the following lines to your :py:const:`INSTALLED_APPS`: 12 | 13 | .. code-block:: python 14 | 15 | INSTALLED_APPS = [ 16 | # "django.contrib.admin", #REMOVE THIS LINE 17 | # ... 18 | "django_custom_admin_pages", 19 | "django_custom_admin_pages.admin.CustomAdminConfig" 20 | # ... 21 | ] 22 | 23 | 4. If you've defined a custom django admin site, you can also subclass :py:class:`django_custom_admin_pages.admin.CustomAdminConfig` and/or `django_custom_admin_pages.admin.CustomAdminSite`: 24 | 25 | .. code-block:: python 26 | 27 | from django.contrib.admin.apps import AdminConfig 28 | from django_custom_admin_pages.admin import CustomAdminConfig, CustomAdminSite 29 | 30 | class MyCustomAdminConfig(AdminConfig): 31 | default_site="path.to.MyCustomAdminSite" 32 | 33 | class MyCustomAdminSite(CustomAdminSite): 34 | pass 35 | 36 | Creating Views 37 | ---------------- 38 | 39 | To create a new custom admin view: 40 | 41 | 1. Create a class-based view in which inherits from ``custom_admin.views.admin_base_view.AdminBaseView``. 42 | 2. Set the view class attribute ``view_name`` to whatever name you want displayed in the admin index. 43 | 3. Register the view similar to how you would register a ModelAdmin using a custom admin function: ``admin.site.register_view(YourView)``. 44 | 4. Use the template ``django_custom_admin_pages.templates.base_custom_admin.html`` as a sample for how to extend the admin templates so that your view has the admin nav. 45 | 46 | 5. *Optional*: Set the view class attribute ``app_label`` to the app you'd like the admin view to display in. This must match a label in ``settings.INSTALLED_APPS``. This will default to a new app called `django_custom_admin_pages` if left unset. 47 | 6. *Optional*: Set the view class attribute ``route_name`` to manually override the automatically generated route_name in ``urlpatterns``. 48 | 49 | Registering Views 50 | ----------------- 51 | 52 | After you create a view, you can register it like you would a ``ModelAdmin``: 53 | 54 | .. code-block:: python 55 | 56 | ### Important: Custom Views Must Be Registered Before Admin URLs are Loaded 57 | 58 | from django.contrib import admin 59 | 60 | admin.site.register_view(MyCustomAdminView) 61 | 62 | 63 | .. warning:: 64 | Be sure to register your views in a file that's imported before your root url conf! Or import all your views in 65 | the root url conf above ``url_patterns`` 66 | 67 | 68 | For example: 69 | 70 | .. code-block:: python 71 | 72 | # project/urls.py 73 | from django.contrib import admin 74 | 75 | # importing view before url_patterns ensures it's registered! 76 | from some_app.views import YourCustomView 77 | 78 | url_patterns = [ 79 | path("admin/", admin.site.urls), 80 | ... 81 | ] 82 | 83 | 84 | Example TemplateView 85 | *********************** 86 | 87 | .. code-block:: python 88 | 89 | ## in django_custom_admin_pages.views.your_special_view.py 90 | from django.contrib import admin 91 | from django.views.generic import TemplateView 92 | from django_custom_admin_pages.views.admin_base_view import AdminBaseView 93 | 94 | class YourCustomView(AdminBaseView, TemplateView): 95 | view_name="My Super Special View" 96 | template_name="my_template.html" 97 | route_name="some-custom-route-name" # if omitted defaults to snake_case of view_name 98 | app_label="my_app" # if omitted defaults to "django_custom_admin_pages". Must match app in settings 99 | 100 | # always call super() on get_context_data and use it to start your context dict. 101 | # the context required to render admin nav-bar is included here. 102 | def get_context_data(self, *args, **kwargs): 103 | context:dict = super().get_context_data(*args, **kwargs) 104 | # add your context ... 105 | return context 106 | 107 | admin.site.register_view(YourCustomView) 108 | 109 | Your template should extend ``admin/base.html`` so you don't lose the nav and admin styling: 110 | 111 | .. code-block:: html 112 | 113 | 114 | 115 | {% extends 'admin/base_site.html' %} 116 | {% load static %} 117 | {% block responsive %} 118 | {{block.super}} 119 | 120 | {% endblock responsive %} 121 | {% block title %} Example Admin View {% endblock %} 122 | {% block content %} 123 |