├── .copier └── package.yml ├── .editorconfig ├── .env.example ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .just ├── copier.just └── documentation.just ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode ├── extensions.json └── settings.json.example ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Justfile ├── LICENSE ├── README.md ├── RELEASING.md ├── docs ├── _static │ └── css │ │ └── custom.css ├── changelog.md ├── conf.py ├── development │ ├── contributing.md │ ├── just.md │ └── releasing.md ├── getting-started.md ├── index.md └── usage.md ├── example ├── __init__.py ├── demo.py ├── navigation.py └── templates │ ├── base.html │ ├── basic.html │ ├── bootstrap4.html │ ├── bootstrap5.html │ ├── extra_context.html │ ├── navs │ ├── basic.html │ ├── bootstrap4.html │ ├── bootstrap5.html │ ├── example_list.html │ ├── extra_context.html │ ├── nested.html │ ├── picocss.html │ ├── tailwind_main.html │ └── tailwind_profile.html │ ├── nested.html │ ├── permissions.html │ ├── picocss.html │ └── tailwind.html ├── noxfile.py ├── pyproject.toml ├── src └── django_simple_nav │ ├── __init__.py │ ├── _templates.py │ ├── _typing.py │ ├── apps.py │ ├── conf.py │ ├── nav.py │ ├── py.typed │ └── templatetags │ ├── __init__.py │ └── django_simple_nav.py ├── tests ├── __init__.py ├── conftest.py ├── navs.py ├── settings.py ├── templates │ └── tests │ │ ├── alternate.html │ │ ├── dummy_nav.html │ │ └── jinja2 │ │ ├── alternate.html │ │ └── dummy_nav.html ├── test_conf.py ├── test_nav.py ├── test_navgroup.py ├── test_navitem.py ├── test_templates.py ├── test_templatetags.py ├── test_version.py ├── urls.py └── utils.py └── uv.lock /.copier/package.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v2024.29 3 | _src_path: gh:westerveltco/django-twc-package 4 | author_email: josh@joshthomas.dev 5 | author_name: Josh Thomas 6 | current_version: 0.12.0 7 | django_versions: 8 | - '4.2' 9 | - '5.0' 10 | - '5.1' 11 | docs_domain: westervelt.dev 12 | github_owner: westerveltco 13 | github_repo: django-simple-nav 14 | module_name: django_simple_nav 15 | package_description: A simple, flexible, and extensible navigation menu for Django. 16 | package_name: django-simple-nav 17 | python_versions: 18 | - '3.9' 19 | - '3.10' 20 | - '3.11' 21 | - '3.12' 22 | - '3.13' 23 | test_django_main: true 24 | versioning_scheme: SemVer 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{,.}{j,J}ustfile] 12 | indent_size = 4 13 | 14 | [*.{py,rst,ini,md}] 15 | indent_size = 4 16 | 17 | [*.py] 18 | line_length = 120 19 | multi_line_output = 3 20 | 21 | [*.{css,html,js,json,jsx,sass,scss,svelte,ts,tsx,yml,yaml}] 22 | indent_size = 2 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | 27 | [{Makefile,*.bat}] 28 | indent_style = tab 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-simple-nav/62565ad7827d52877592a6e1fe60e0501067546c/.env.example -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @westerveltco/oss 2 | /docs/ @westerveltco/web-dev-docs 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | timezone: America/Chicago 8 | labels: 9 | - 🤖 dependabot 10 | groups: 11 | gha: 12 | patterns: 13 | - "*" 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | test: 12 | uses: ./.github/workflows/test.yml 13 | 14 | pypi: 15 | runs-on: ubuntu-latest 16 | needs: test 17 | environment: release 18 | permissions: 19 | contents: read 20 | id-token: write 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v6 26 | with: 27 | enable-cache: true 28 | version: "0.4.x" 29 | 30 | - name: Build package 31 | run: | 32 | uv build 33 | 34 | - name: Publish to PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_call: 8 | 9 | concurrency: 10 | group: test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PYTHONUNBUFFERED: "1" 15 | FORCE_COLOR: "1" 16 | UV_VERSION: "0.4.x" 17 | 18 | jobs: 19 | generate-matrix: 20 | runs-on: ubuntu-latest 21 | outputs: 22 | matrix: ${{ steps.set-matrix.outputs.matrix }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v6 28 | with: 29 | enable-cache: true 30 | version: ${{ env.UV_VERSION }} 31 | 32 | - id: set-matrix 33 | run: | 34 | uv run nox --session "gha_matrix" 35 | 36 | test: 37 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 38 | runs-on: ubuntu-latest 39 | needs: generate-matrix 40 | strategy: 41 | fail-fast: false 42 | matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} 43 | steps: 44 | - uses: actions/checkout@v4 45 | 46 | - name: Install uv 47 | uses: astral-sh/setup-uv@v6 48 | with: 49 | enable-cache: true 50 | version: ${{ env.UV_VERSION }} 51 | 52 | - name: Run tests 53 | run: | 54 | uv run nox --session "tests(python='${{ matrix.python-version }}', django='${{ matrix.django-version }}')" 55 | 56 | tests: 57 | runs-on: ubuntu-latest 58 | needs: test 59 | if: always() 60 | steps: 61 | - name: OK 62 | if: ${{ !(contains(needs.*.result, 'failure')) }} 63 | run: exit 0 64 | - name: Fail 65 | if: ${{ contains(needs.*.result, 'failure') }} 66 | run: exit 1 67 | 68 | types: 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: actions/checkout@v4 72 | 73 | - name: Install uv 74 | uses: astral-sh/setup-uv@v6 75 | with: 76 | enable-cache: true 77 | version: ${{ env.UV_VERSION }} 78 | 79 | - name: Run type checks 80 | run: | 81 | uv run nox --session "types" 82 | 83 | coverage: 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | 88 | - name: Install uv 89 | uses: astral-sh/setup-uv@v6 90 | with: 91 | enable-cache: true 92 | version: ${{ env.UV_VERSION }} 93 | 94 | - name: Generate code coverage 95 | run: | 96 | uv run nox --session "coverage" 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Logs 163 | logs 164 | *.log 165 | npm-debug.log* 166 | yarn-debug.log* 167 | yarn-error.log* 168 | pnpm-debug.log* 169 | lerna-debug.log* 170 | 171 | node_modules 172 | dist 173 | dist-ssr 174 | *.local 175 | 176 | # Editor directories and files 177 | .vscode/* 178 | !.vscode/extensions.json 179 | !.vscode/*.example 180 | .idea 181 | .DS_Store 182 | *.suo 183 | *.ntvs* 184 | *.njsproj 185 | *.sln 186 | *.sw? 187 | 188 | staticfiles/ 189 | mediafiles/ 190 | 191 | # pyright config for nvim-lspconfig 192 | pyrightconfig.json 193 | 194 | # auto-generated apidocs 195 | apidocs/ 196 | -------------------------------------------------------------------------------- /.just/copier.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/copier.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | # Create a copier answers file 14 | [no-cd] 15 | copy TEMPLATE_PATH DESTINATION_PATH=".": 16 | uv run copier copy --trust {{ TEMPLATE_PATH }} {{ DESTINATION_PATH }} 17 | 18 | # Recopy the project from the original template 19 | [no-cd] 20 | recopy ANSWERS_FILE *ARGS: 21 | uv run copier recopy --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 22 | 23 | # Loop through all answers files and recopy the project using copier 24 | [no-cd] 25 | @recopy-all *ARGS: 26 | for file in `ls .copier/`; do just copier recopy .copier/$file "{{ ARGS }}"; done 27 | 28 | # Update the project using a copier answers file 29 | [no-cd] 30 | update ANSWERS_FILE *ARGS: 31 | uv run copier update --trust --answers-file {{ ANSWERS_FILE }} {{ ARGS }} 32 | 33 | # Loop through all answers files and update the project using copier 34 | [no-cd] 35 | @update-all *ARGS: 36 | for file in `ls .copier/`; do just copier update .copier/$file "{{ ARGS }}"; done 37 | -------------------------------------------------------------------------------- /.just/documentation.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/documentation.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | # Build documentation using Sphinx 14 | [no-cd] 15 | build LOCATION="docs/_build/html": cog 16 | uv run --extra docs sphinx-build docs {{ LOCATION }} 17 | 18 | # Serve documentation locally 19 | [no-cd] 20 | serve PORT="8000": cog 21 | #!/usr/bin/env sh 22 | HOST="localhost" 23 | if [ -f "/.dockerenv" ]; then 24 | HOST="0.0.0.0" 25 | fi 26 | uv run --extra docs sphinx-autobuild docs docs/_build/html --host "$HOST" --port {{ PORT }} 27 | 28 | [no-cd] 29 | [private] 30 | cog: 31 | uv run --extra docs cog -r docs/development/just.md 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-toml 11 | - id: check-yaml 12 | 13 | - repo: https://github.com/adamchainz/django-upgrade 14 | rev: 1.25.0 15 | hooks: 16 | - id: django-upgrade 17 | args: [--target-version, "4.2"] 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.11.12 21 | hooks: 22 | - id: ruff 23 | args: [--fix] 24 | - id: ruff-format 25 | 26 | - repo: https://github.com/adamchainz/blacken-docs 27 | rev: 1.19.1 28 | hooks: 29 | - id: blacken-docs 30 | alias: autoformat 31 | additional_dependencies: 32 | - black==22.12.0 33 | 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: v4.0.0-alpha.8 36 | hooks: 37 | - id: prettier 38 | # lint the following with prettier: 39 | # - javascript 40 | # - typescript 41 | # - JSX/TSX 42 | # - CSS 43 | # - yaml 44 | # ignore any minified code 45 | files: '^(?!.*\.min\..*)(?P[\w-]+(\.[\w-]+)*\.(js|jsx|ts|tsx|yml|yaml|css))$' 46 | 47 | - repo: https://github.com/djlint/djLint 48 | rev: v1.36.4 49 | hooks: 50 | - id: djlint-reformat-django 51 | - id: djlint-django 52 | 53 | - repo: local 54 | hooks: 55 | - id: rustywind 56 | name: rustywind Tailwind CSS class linter 57 | language: node 58 | additional_dependencies: 59 | - rustywind@0.21.0 60 | entry: rustywind 61 | args: [--write] 62 | types_or: [html, jsx, tsx] 63 | 64 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 65 | rev: v2.14.0 66 | hooks: 67 | - id: pretty-format-toml 68 | args: [--autofix] 69 | 70 | - repo: https://github.com/abravalheri/validate-pyproject 71 | rev: v0.24.1 72 | hooks: 73 | - id: validate-pyproject 74 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.12" 10 | commands: 11 | - asdf plugin add just && asdf install just latest && asdf global just latest 12 | - asdf plugin add uv && asdf install uv latest && asdf global uv latest 13 | - just docs cog 14 | - just docs build $READTHEDOCS_OUTPUT/html 15 | 16 | sphinx: 17 | configuration: docs/conf.py 18 | 19 | formats: 20 | - pdf 21 | - epub 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | "monosans.djlint", 7 | "ms-python.black-formatter", 8 | "ms-python.pylint", 9 | "ms-python.python", 10 | "ms-python.vscode-pylance", 11 | "skellock.just", 12 | "tamasfe.even-better-toml" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.associations": { 4 | "Justfile": "just" 5 | }, 6 | "ruff.organizeImports": true, 7 | "[django-html][handlebars][hbs][mustache][jinja][jinja-html][nj][njk][nunjucks][twig]": { 8 | "editor.defaultFormatter": "monosans.djlint" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | - Josh Thomas 4 | - Jeff Triplett [@jefftriplett](https://github.com/jefftriplett) 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project attempts to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 18 | 19 | ## [Unreleased] 20 | 21 | ## [0.12.0] 22 | 23 | ### Changed 24 | 25 | - Bumped `django-twc-package` template to v2024.29. 26 | - Switched project and dependency management over to use `uv` across the board (`pyproject.toml`, `Justfile` recipes, GHA workflows). 27 | 28 | ### Removed 29 | 30 | - Dropped support for Python 3.8. 31 | 32 | ## [0.11.0] 33 | 34 | ### Fixed 35 | 36 | - Checking whether a `NavItem` or `NavGroup` is active now takes into account the URL scheme and domain name for both the nav item and request. 37 | 38 | ## [0.10.0] 39 | 40 | ### Added 41 | 42 | - Support for Python 3.13. 43 | 44 | ### Changed 45 | 46 | - Bumped `django-twc-package` template to v2024.23. 47 | - Removed `westerveltco/setup-ci-action` from GitHub Actions workflows. 48 | 49 | ## [0.9.0] 50 | 51 | ### Changed 52 | 53 | - Updated `NavItem.get_active` to allow for using URLs that contain query strings. 54 | 55 | ## [0.8.0] 56 | 57 | ### Changed 58 | 59 | - Updated `NavItem.url` and `NavItem.get_url` to allow for using a callable. This allows `NavItem.url` to support `django.urls.reverse` or `django.urls.reverse_lazy` primarily, but it can be any callable as long as it returns a string. 60 | 61 | ## [0.7.0] 62 | 63 | ### Added 64 | 65 | - `NavItem` and `NavGroup` now both have a `get_context_data` that returns the context needed for template rendering. 66 | - `NavItem` and `NavGroup` now both have a `get_url` method for returning the URL for the item. 67 | - `NavItem` and `NavGroup` now both have a `get_active` method for returning whether the item is active or not, meaning it's the URL currently being requested. 68 | - `NavItem` and `NavGroup` now both have a `check_permissions` method for checking whether the item should be rendered for a given request. 69 | - `NavItem` and `NavGroup` now support using a callable in the list of `permissions`. This callable should take an `HttpRequest` and return a `bool` indicating whether the item should be rendered for a given request. 70 | - The `Nav` class now has a `get_template` method that returns the template to render. This method takes an optional `template_name` argument, and if not provided is taken from the `get_template_name` method. If overridden, you can return a string as a way to embed a template directly in the `Nav` definition. 71 | - `NavItem` now has a `get_items` method. This is to aid a future refactor. 72 | 73 | ### Changed 74 | 75 | - Internals of library have been refactored to slightly simplify it, including `Nav`, `NavGroup`, `NavItem` and the `django_simple_nav` templatetag. 76 | - `Nav.get_items` now returns a list of `NavGroup` or `NavItem`, instead of a list of `RenderedNavItem`. 77 | - Check for the existence of a user attached to the `request` object passed in to `django_simple_nav.permissions.check_item_permissions` has been moved to allow for an early return if there is no user. There are instances where the `django.contrib.auth` app can be installed, but no user is attached to the request object. This change will allow this function to correctly be used in those instances. 78 | - Now using v2024.20 of `django-twc-package`. 79 | - `NavGroup` is now marked as active if the request path matches it's URL (if set) **or** and of its items' URLs. 80 | 81 | ### Removed 82 | 83 | - The `extra_context` attribute of `NavItem` and `NavGroup` now only renders the contents of the dictionary to the template context. Previously it did that as well as provided `extra_context` to the context. If this sounds confusing, that's because it kinda is. 😅 This basically just means instead of two places to get the extra context (`extra_context` and the keys provided within the `extra_context` attribute), there is now just one (the keys provided within the `extra_context` attribute). 84 | - `RenderedNavItem` has been removed and it's functionality refactored into both `NavItem` and `NavGroup`. This should not affect the public API of this library, but I thought it should be noted. 85 | - `django_simple_nav.permissions` module has been removed and it's functionality refactored into `NavItem`. 86 | - Dropped support for Django 3.2. 87 | 88 | ### Fixed 89 | 90 | - `active` boolean for a `NavItem` should now accurately match the request URL, taking into account any potential nesting and a project's `APPEND_SLASH` setting. 91 | - The permissions check for `NavGroup` has been fixed to apply to the child items as well. Previously, it only checked the top-level permissions on the `NavGroup` instance itself. If the items within the `NavGroup` have permissions defined, they will now be checked and filtered out. If the check ends up filtering all of the items out and the `NavGroup` has no url set, then it will not be rendered. 92 | 93 | ## [0.6.0] 94 | 95 | ### Added 96 | 97 | - Added two new methods to `Nav`: `get_items` and `get_template_name`. These should allow for further flexibility and customization of rendering the `Nav`. 98 | 99 | ### Changed 100 | 101 | - Now using v2024.16 of `django-twc-package`. 102 | 103 | ### Fixed 104 | 105 | - Active nav item matching is now correctly using the `url` property on `RenderedNavItem`. 106 | 107 | ## [0.5.1] 108 | 109 | ### Added 110 | 111 | - Added the requisite `py.typed` file to the package, so that it plays nicely when type-checking in projects using `django-simple-nav`. 112 | 113 | ## [0.5.0] 114 | 115 | ### Added 116 | 117 | - An number of examples have been added to a new `example` directory. These examples are intended to demonstrate various ways how to use `django-simple-nav` in a Django project and include basic usage and usage with some popular CSS frameworks. 118 | 119 | ### Changed 120 | 121 | - `check_item_permission` now takes a `request` argument instead of a `user` argument. 122 | 123 | ### Fixed 124 | 125 | - `check_item_permission` now explicitly checks if `django.contrib.auth` is installed before attempting to check if a user has a permission. If it is not, it will return `True` by default and log a warning. 126 | - The `request` object is now passed to `render_to_string` when rendering the navigation template, so that the `request` object is available in the template context. This allows for nesting the `django_simple_nav` template tag within another `django_simple_nav` template tag, and having the `request` object available in the nested template. 127 | 128 | ## [0.4.0] 129 | 130 | ### Added 131 | 132 | - The `Nav` class now has two new methods: `get_context_data` and `render`. These methods are used to render the navigation to a template. These new methods give greater flexibility for customizing the rendering of the navigation, as they can be overridden when defining a new `Nav`. 133 | - `Nav.get_context_data` method takes a Django `HttpRequest` object and returns a dictionary of context data that can be used to render the navigation to a template. 134 | - `Nav.render` method takes a Django `HttpRequest` object and an optional template name and renders the navigation to a template, returning the rendered template as a string. 135 | 136 | ### Removed 137 | 138 | - `Nav.render_from_request` method has been removed. This was only used within the template tag to render a `Nav` template from an `HttpRequest` object. It has been removed in favor of the new `Nav.get_context_data` and `Nav.render` methods. 139 | 140 | ## [0.3.0] 141 | 142 | ### Added 143 | 144 | - `NavGroup` and `NavItem` now has a new `extra_context` attribute. This allows for passing additional context to the template when rendering the navigation, either via the extra attribute (`item.foo`) or the `extra_context` attribute itself (`item.extra_context.foo`). 145 | 146 | ### Changed 147 | 148 | - Now using v2024.13 of `django-twc-package`. 149 | 150 | ### Fixed 151 | 152 | - `RenderedNavItem.items` property now correctly returns a list of `RenderedNavItem` objects, rather than a list of `NavItem` objects. This fixes a bug where the properties that should be available (e.g. `active`, `url`, etc.) were not available when iterating over the `RenderedNavItem.items` list if the item was a `NavGroup` object with child items. 153 | 154 | ## [0.2.0] 155 | 156 | ### Added 157 | 158 | - The `django_simple_nav` template tag can now take an instance of a `Nav` class, in addition to a `Nav` dotted path string. This should give greater flexibility for rendering a `Nav`, as it can now be overridden on a per-view/template basis. 159 | 160 | ### Changed 161 | 162 | - Now using [`django-twc-package`](https://github.com/westerveltco/django-twc-package) template for repository and package structure. 163 | 164 | ## [0.1.0] 165 | 166 | Initial release! 🎉 167 | 168 | ### Added 169 | 170 | - A group of navigation classes -- `Nav`, `NavGroup`, and `NavItem` -- that can be used together to build a simple navigation structure. 171 | - `Nav` is the main container for a navigation structure. 172 | - `NavGroup` is a container for a group of `NavItem` objects. 173 | - `NavItem` is a single navigation item. 174 | - A `django_simple_nav` template tag that renders a `Nav` object to a template. The template tag takes a string represented the dotted path to a `Nav` object and renders it to the template. 175 | - Navigation item urls can be either a URL string (e.g. `https://example.com/about/` or `/about/`) or a Django URL name (e.g. `about-view`). When rendering out to the template, the template tag will resolve the URL name to the actual URL. 176 | - Navigation items also can take a list of permissions to control the visibility of the item. The permissions can be user attributes (e.g. `is_staff`, `is_superuser`, etc.) or a specific permission (e.g. `auth.add_user`). 177 | - Navigation items are marked as `active` if the current request path matches the item's URL. This is can be useful for highlighting the current page in the navigation. 178 | - A `Nav` object's template can either be set as a class attribute (`template_name`) or passed in as a keyword argument when rendering the template tag. This allows for easy customization of the navigation structure on a per-template or per-view basis. 179 | - Initial documentation. 180 | - Initial tests. 181 | - Initial CI/CD (GitHub Actions). 182 | 183 | ### New Contributors 184 | 185 | - Josh Thomas (maintainer) 186 | - Jeff Triplett [@jefftriplett](https://github.com/jefftriplett) 187 | 188 | [unreleased]: https://github.com/westerveltco/django-simple-nav/compare/v0.12.0...HEAD 189 | [0.1.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.1.0 190 | [0.2.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.2.0 191 | [0.3.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.3.0 192 | [0.4.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.4.0 193 | [0.5.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.5.0 194 | [0.5.1]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.5.1 195 | [0.6.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.6.0 196 | [0.7.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.7.0 197 | [0.8.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.8.0 198 | [0.9.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.9.0 199 | [0.10.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.10.0 200 | [0.11.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.11.0 201 | [0.12.0]: https://github.com/westerveltco/django-simple-nav/releases/tag/v0.12.0 202 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! Besides code contributions, this includes things like documentation improvements, bug reports, and feature requests. 4 | 5 | You should first check if there is a [GitHub issue](https://github.com/westerveltco/django-simple-nav/issues) already open or related to what you would like to contribute. If there is, please comment on that issue to let others know you are working on it. If there is not, please open a new issue to discuss your contribution. 6 | 7 | Not all contributions need to start with an issue, such as typo fixes in documentation or version bumps to Python or Django that require no internal code changes, but generally, it is a good idea to open an issue first. 8 | 9 | We adhere to Django's Code of Conduct in all interactions and expect all contributors to do the same. Please read the [Code of Conduct](https://www.djangoproject.com/conduct/) before contributing. 10 | 11 | ## Setup 12 | 13 | The following setup steps assume you are using a Unix-like operating system, such as Linux or macOS, and that you have a [supported](https://django-simple-nav.westervelt.dev/en/latest/#requirements) version of Python installed. If you are using Windows, you will need to adjust the commands accordingly. If you do not have Python installed, you can visit [python.org](https://www.python.org/) for instructions on how to install it for your operating system. 14 | 15 | 1. Fork the repository and clone it locally. 16 | 2. Create a virtual environment and activate it. You can use whatever tool you prefer for this. Below is an example using the Python standard library's `venv` module: 17 | 18 | ```shell 19 | python -m venv venv 20 | source venv/bin/activate 21 | ``` 22 | 23 | 3. Install `django-simple-nav` and the `dev` dependencies in editable mode: 24 | 25 | ```shell 26 | python -m pip install --editable '.[dev]' 27 | # or using [just](#just) 28 | just bootstrap 29 | ``` 30 | 31 | ## Testing 32 | 33 | We use [`pytest`](https://docs.pytest.org/) for testing and [`nox`](https://nox.thea.codes/) to run the tests in multiple environments. 34 | 35 | To run the test suite against the default versions of Python (lower bound of supported versions) and Django (lower bound of LTS versions), run: 36 | 37 | ```shell 38 | python -m nox --session "test" 39 | # or using [just](#just) 40 | just test 41 | ``` 42 | 43 | To run the test suite against all supported versions of Python and Django, run: 44 | 45 | ```shell 46 | python -m nox --session "tests" 47 | # or using [just](#just) 48 | just testall 49 | ``` 50 | 51 | ## `just` 52 | 53 | [`just`](https://github.com/casey/just) is a command runner that is used to run common commands, similar to `make` or `invoke`. A `Justfile` is provided at the base of the repository, which contains commands for common development tasks, such as running the test suite or linting. 54 | 55 | To see a list of all available commands, ensure `just` is installed and run the following command at the base of the repository: 56 | 57 | ```shell 58 | just 59 | ``` 60 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | set unstable := true 3 | 4 | mod copier ".just/copier.just" 5 | mod docs ".just/documentation.just" 6 | 7 | [private] 8 | default: 9 | @just --list 10 | 11 | [private] 12 | fmt: 13 | @just --fmt 14 | @just copier fmt 15 | @just docs fmt 16 | 17 | [private] 18 | nox SESSION *ARGS: 19 | uv run nox --session "{{ SESSION }}" -- "{{ ARGS }}" 20 | 21 | bootstrap: 22 | uv python install 23 | uv sync --frozen 24 | 25 | coverage: 26 | @just nox coverage 27 | 28 | demo: 29 | @just nox demo 30 | 31 | lint: 32 | uv run --with pre-commit-uv pre-commit run --all-files 33 | just fmt 34 | 35 | lock *ARGS: 36 | uv lock {{ ARGS }} 37 | 38 | test *ARGS: 39 | @just nox test {{ ARGS }} 40 | 41 | testall *ARGS: 42 | @just nox tests {{ ARGS }} 43 | 44 | types *ARGS: 45 | @just nox types {{ ARGS }} 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Josh Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-simple-nav 2 | 3 | 4 | [![PyPI](https://img.shields.io/pypi/v/django-simple-nav)](https://pypi.org/project/django-simple-nav/) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-simple-nav) 6 | ![Django Version](https://img.shields.io/badge/django-4.2%20%7C%205.0%20%7C%205.1-%2344B78B?labelColor=%23092E20) 7 | 8 | 9 | 10 | 11 | `django-simple-nav` is a Python/Django application designed to simplify the integration of navigation and menu bars in your Django projects. With a straightforward API and customizable options, you can easily add and manage navigational elements in your web applications. It is designed to be simple to start with, but flexible enough to handle complex navigation structures while maintaining that same simplicity. 12 | 13 | ## Requirements 14 | 15 | - Python 3.9, 3.10, 3.11, 3.12, 3.13 16 | - Django 4.2, 5.0, 5.1 17 | 18 | ## Installation 19 | 20 | 21 | 1. **Install the package from PyPI.** 22 | 23 | ```bash 24 | python -m pip install django-simple-nav 25 | ``` 26 | 27 | 1. **Add `django_simple_nav` to `INSTALLED_APPS`.** 28 | 29 | After installation, add `django_simple_nav` to your `INSTALLED_APPS` in your Django settings: 30 | 31 | ```python 32 | INSTALLED_APPS = [ 33 | # ..., 34 | "django_simple_nav", 35 | # ..., 36 | ] 37 | ``` 38 | 39 | 1. **Adjust your Django project's settings.** 40 | 41 | If you plan to use the permissions feature of `django-simple-nav` to filter your navigation items based on the `request.user`, `django.contrib.auth` and `django.contrib.contenttypes` must be added to your `INSTALLED_APPS` as well: 42 | 43 | ```python 44 | INSTALLED_APPS = [ 45 | # ..., 46 | "django.contrib.auth", 47 | "django.contrib.contenttypes", 48 | # ..., 49 | ] 50 | ``` 51 | 52 | If you do not add `django.contrib.auth` to your `INSTALLED_APPS` and you define any permissions for your navigation items, `django-simple-nav` will simply ignore the permissions and render all items regardless of whether the permission check is `True` or `False.` 53 | 54 | 55 | ## Getting Started 56 | 57 | 58 | 1. **Create a navigation definition.** 59 | 60 | Define your navigation structure in a Python file. This file can be located anywhere in your Django project, provided it's importable. You can also split the navigations across multiple files if desired. 61 | 62 | A good starting point is to create a single `nav.py` or `navigation.py` file in your Django project's main configuration directory (where your `settings.py` file is located). 63 | 64 | `django-simple-nav` provides three classes to help you define your navigation structure: 65 | 66 | - `Nav`: The main container for a navigation structure. It has two required attributes: 67 | - `template_name`: The name of the template to render the navigation structure. 68 | - `items`: A list of `NavItem` or `NavGroup` objects that represent the navigation structure. 69 | - `NavGroup`: A container for a group of `NavItem` or `NavGroup` objects. It has two required and three optional attributes: 70 | - `title`: The title of the group. 71 | - `items`: A list of `NavItem` or `NavGroup`objects that represent the structure of the group. 72 | - `url` (optional): The URL of the group. If not provided, the group will not be a link but just a container for the items. 73 | - `permissions` (optional): A list of permissions that control the visibility of the group. These permissions can be `User` attributes (e.g. `is_authenticated`, `is_staff`, `is_superuser`), Django permissions (e.g. `myapp.django_perm`), or a callable that takes an `HttpRequest` and returns a `bool`. 74 | - `extra_context` (optional): A dictionary of additional context to pass to the template when rendering the navigation. 75 | - `NavItem`: A single navigation item. It has two required and three optional attributes: 76 | - `title`: The title of the item. 77 | - `url`: The URL of the item. This can be a URL string (e.g. `https://example.com/about/` or `/about/`) or a Django URL name (e.g. `about-view`). 78 | - `permissions` (optional): A list of permissions that control the visibility of the item. These permissions can be `User` attributes (e.g. `is_authenticated`, `is_staff`, `is_superuser`), Django permissions (e.g. `myapp.django_perm`), or a callable that takes an `HttpRequest` and returns a `bool`. 79 | - `extra_context` (optional): A dictionary of additional context to pass to the template when rendering the navigation. 80 | 81 | Here's an example configuration: 82 | 83 | ```python 84 | # config/nav.py 85 | from django.http import HttpRequest 86 | 87 | from django_simple_nav.nav import Nav 88 | from django_simple_nav.nav import NavGroup 89 | from django_simple_nav.nav import NavItem 90 | 91 | 92 | def simple_permissions_check(request: HttpRequest) -> bool: 93 | return True 94 | 95 | 96 | class MainNav(Nav): 97 | template_name = "main_nav.html" 98 | items = [ 99 | NavItem(title="Relative URL", url="/relative-url"), 100 | NavItem(title="Absolute URL", url="https://example.com/absolute-url"), 101 | NavItem(title="Internal Django URL by Name", url="fake-view"), 102 | NavGroup( 103 | title="Group", 104 | url="/group", 105 | items=[ 106 | NavItem(title="Relative URL", url="/relative-url"), 107 | NavItem(title="Absolute URL", url="https://example.com/absolute-url"), 108 | NavItem(title="Internal Django URL by Name", url="fake-view"), 109 | ], 110 | ), 111 | NavGroup( 112 | title="Container Group", 113 | items=[ 114 | NavItem(title="Item", url="#"), 115 | ], 116 | ), 117 | NavItem( 118 | title="is_authenticated Item", url="#", permissions=["is_authenticated"] 119 | ), 120 | NavItem(title="is_staff Item", url="#", permissions=["is_staff"]), 121 | NavItem(title="is_superuser Item", url="#", permissions=["is_superuser"]), 122 | NavItem( 123 | title="myapp.django_perm Item", url="#", permissions=["myapp.django_perm"] 124 | ), 125 | NavItem( 126 | title="Item with callable permission", 127 | url="#", 128 | permissions=[simple_permissions_check], 129 | ), 130 | NavGroup( 131 | title="Group with Extra Context", 132 | items=[ 133 | NavItem( 134 | title="Item with Extra Context", 135 | url="#", 136 | extra_context={"foo": "bar"}, 137 | ), 138 | ], 139 | extra_context={"baz": "qux"}, 140 | ), 141 | ] 142 | ``` 143 | 144 | 2. **Create a template for the navigation.** 145 | 146 | Create a template to render the navigation structure. This is just a standard Django template so you can use any Django template features you like. 147 | 148 | The template will be passed an `items` variable in the context representing the structure of the navigation, containing the `NavItem` and `NavGroup` objects defined in your navigation. 149 | 150 | Any items with permissions attached will automatically filtered out before rendering the template based on the request user's permissions, so you don't need to worry about that in your template. 151 | 152 | Items with extra context will have that context passed to the template when rendering the navigation, which you can access directly. 153 | 154 | For example, given the above example `MainNav`, you could create a `main_nav.html` template: 155 | 156 | ```htmldjango 157 | 158 | 178 | ``` 179 | 180 | 1. **Integrate navigation in templates.**: 181 | 182 | Use the `django_simple_nav` template tag in your Django templates where you want to display the navigation. 183 | 184 | For example: 185 | 186 | ```htmldjango 187 | 188 | {% load django_simple_nav %} 189 | 190 | {% block navigation %} 191 | 194 | {% endblock navigation %} 195 | ``` 196 | 197 | The template tag can either take a string representing the import path to your navigation definition or an instance of your navigation class: 198 | 199 | ```python 200 | # example_app/views.py 201 | from config.nav import MainNav 202 | 203 | 204 | def example_view(request): 205 | return render(request, "example_app/example_template.html", {"nav": MainNav()}) 206 | ``` 207 | 208 | ```htmldjango 209 | 210 | {% extends "base.html" %} 211 | {% load django_simple_nav %} 212 | 213 | {% block navigation %} 214 | 217 | {% endblock navigation %} 218 | ``` 219 | 220 | Additionally, the template tag can take a second argument to specify the template to use for rendering the navigation. This is useful if you want to use the same navigation structure in multiple places but render it differently. 221 | 222 | ```htmldjango 223 | 224 | {% load django_simple_nav %} 225 | 226 |
227 | {% django_simple_nav "path.to.MainNav" "footer_nav.html" %} 228 |
229 | ``` 230 | 231 | After configuring your navigation, you can use it across your Django project by calling the `django_simple_nav` template tag in your templates. This tag dynamically renders navigation based on your defined structure, ensuring a consistent and flexible navigation experience throughout your application. 232 | 233 | 234 | ## Examples 235 | 236 | The [`example`](example/) directory contains a simple Django project that demonstrates how to use `django-simple-nav`. The example project includes a navigation definitions for a few different scenarios as well as some popular CSS frameworks. 237 | 238 | You can run the example project by following these steps. These steps assume you have `git` and `python` installed on your system and are using a Unix-like shell. If you are using Windows, you may need to adjust the commands accordingly. 239 | 240 | 1. **Clone the repository.** 241 | 242 | ```bash 243 | git clone https://github.com/westerveltco/django-simple-nav 244 | cd django-simple-nav 245 | ``` 246 | 247 | 1. **Create a new virtual environment, activate it, and install `django-simple-nav`.** 248 | 249 | ```bash 250 | python -m venv venv 251 | source venv/bin/activate 252 | python -m pip install . 253 | ``` 254 | 255 | 1. **Run the example project.** 256 | 257 | ```bash 258 | python example/demo.py 259 | ``` 260 | 261 | 1. **Open your browser to `http://localhost:8000` to see the examples in action.** 262 | 263 | ## Documentation 264 | 265 | Please refer to the [documentation](https://django-simple-nav.westervelt.dev/) for more information. 266 | 267 | ## License 268 | 269 | `django-simple-nav` is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information. 270 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing a New Version 2 | 3 | When it comes time to cut a new release, follow these steps: 4 | 5 | 1. Create a new git branch off of `main` for the release. 6 | 7 | Prefer the convention `release-`, where `` is the next incremental version number (e.g. `release-v0.11.0` for version 0.11.0). 8 | 9 | ```shell 10 | git checkout -b release-v 11 | ``` 12 | 13 | However, the branch name is not *super* important, as long as it is not `main`. 14 | 15 | 2. Update the version number across the project using the `bumpver` tool. See [this section](#choosing-the-next-version-number) for more details about choosing the correct version number. 16 | 17 | The `pyproject.toml` in the base of the repository contains a `[tool.bumpver]` section that configures the `bumpver` tool to update the version number wherever it needs to be updated and to create a commit with the appropriate commit message. 18 | 19 | `bumpver` is included as a development dependency, so you should already have it installed if you have installed the development dependencies for this project. If you do not have the development dependencies installed, you can install them with either of the following commands: 20 | 21 | ```shell 22 | python -m pip install --editable '.[dev]' 23 | # or using [just](CONTRIBUTING.md#just) 24 | just bootstrap 25 | ``` 26 | 27 | Then, run `bumpver` to update the version number, with the appropriate command line arguments. See the [`bumpver` documentation](https://github.com/mbarkhau/bumpver) for more details. 28 | 29 | **Note**: For any of the following commands, you can add the command line flag `--dry` to preview the changes without actually making the changes. 30 | 31 | Here are the most common commands you will need to run: 32 | 33 | ```shell 34 | bumpver update --patch # for a patch release 35 | bumpver update --minor # for a minor release 36 | bumpver update --major # for a major release 37 | ``` 38 | 39 | To release a tagged version, such as a beta or release candidate, you can run: 40 | 41 | ```shell 42 | bumpver update --tag=beta 43 | # or 44 | bumpver update --tag=rc 45 | ``` 46 | 47 | Running these commands on a tagged version will increment the tag appropriately, but will not increment the version number. 48 | 49 | To go from a tagged release to a full release, you can run: 50 | 51 | ```shell 52 | bumpver update --tag=final 53 | ``` 54 | 55 | 3. Ensure the [CHANGELOG](https://github.com/westerveltco/django-simple-nav/blob/main/CHANGELOG.md) is up to date. If updates are needed, add them now in the release branch. 56 | 57 | 4. Create a pull request from the release branch to `main`. 58 | 59 | 5. Once CI has passed and all the checks are green ✅, merge the pull request. 60 | 61 | 6. Draft a [new release](https://github.com/westerveltco/django-simple-nav/releases/new) on GitHub. 62 | 63 | Use the version number with a leading `v` as the tag name (e.g. `v0.11.0`). 64 | 65 | Allow GitHub to generate the release title and release notes, using the 'Generate release notes' button above the text box. If this is a final release coming from a tagged release (or multiple tagged releases), make sure to copy the release notes from the previous tagged release(s) to the new release notes (after the release notes already generated for this final release). 66 | 67 | If this is a tagged release, make sure to check the 'Set as a pre-release' checkbox. 68 | 69 | 7. Once you are satisfied with the release, publish the release. As part of the publication process, GitHub Actions will automatically publish the new version of the package to PyPI. 70 | 71 | ## Choosing the Next Version Number 72 | 73 | We try our best to adhere to [Semantic Versioning](https://semver.org/), but we do not promise to follow it perfectly (and let's be honest, this is the case with a lot of projects using SemVer). 74 | 75 | In general, use your best judgement when choosing the next version number. If you are unsure, you can always ask for a second opinion from another contributor. 76 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* 2 | no idea if this will screw a lot up with the furo theme 3 | but this does fix the line of badges in the README. only 4 | one of them has a link which furo makes the vertical-align 5 | different than just a standard img 6 | */ 7 | p a.reference img { 8 | vertical-align: inherit; 9 | } 10 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import sys 11 | 12 | # import django 13 | 14 | # -- Path setup -------------------------------------------------------------- 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | 23 | # -- Django setup ----------------------------------------------------------- 24 | # This is required to import Django code in Sphinx using autodoc. 25 | 26 | # os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 27 | # django.setup() 28 | 29 | 30 | # -- Project information ----------------------------------------------------- 31 | 32 | project = "django-simple-nav" 33 | copyright = "2023, Josh Thomas" 34 | author = "Josh Thomas" 35 | 36 | 37 | # -- General configuration --------------------------------------------------- 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "autodoc2", 44 | "myst_parser", 45 | "sphinx_copybutton", 46 | "sphinx_inline_tabs", 47 | "sphinx.ext.napoleon", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = [] 57 | 58 | 59 | # -- MyST configuration ------------------------------------------------------ 60 | myst_heading_anchors = 3 61 | 62 | # -- Options for autodoc2 ----------------------------------------------------- 63 | autodoc2_packages = [f"../src/{project.replace('-', '_')}"] 64 | autodoc2_render_plugin = "myst" 65 | 66 | # -- Options for sphinx_copybutton ----------------------------------------------------- 67 | copybutton_selector = "div.copy pre" 68 | copybutton_prompt_text = "$ " 69 | 70 | # -- Options for HTML output ------------------------------------------------- 71 | 72 | # The theme to use for HTML and HTML Help pages. See the documentation for 73 | # a list of builtin themes. 74 | # 75 | html_theme = "furo" 76 | 77 | # Add any paths that contain custom static files (such as style sheets) here, 78 | # relative to this directory. They are copied after the builtin static files, 79 | # so a file named "default.css" will overwrite the builtin "default.css". 80 | html_static_path = ["_static"] 81 | 82 | html_css_files = [ 83 | "css/custom.css", 84 | ] 85 | 86 | html_title = project 87 | 88 | html_theme_options = { 89 | "footer_icons": [ 90 | { 91 | "name": "GitHub", 92 | "url": "https://github.com/westerveltco/django-simple-nav", 93 | "html": """ 94 | 95 | 96 | 97 | """, 98 | "class": "", 99 | }, 100 | ], 101 | } 102 | 103 | html_sidebars = { 104 | "**": [ 105 | "sidebar/brand.html", 106 | "sidebar/search.html", 107 | "sidebar/scroll-start.html", 108 | "sidebar/navigation.html", 109 | "sidebar/scroll-end.html", 110 | "sidebar/variant-selector.html", 111 | ] 112 | } 113 | -------------------------------------------------------------------------------- /docs/development/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../CONTRIBUTING.md 2 | 3 | ``` 4 | 5 | See the [documentation](./just.md) for more information. 6 | -------------------------------------------------------------------------------- /docs/development/just.md: -------------------------------------------------------------------------------- 1 | # Justfile 2 | 3 | This project uses [Just](https://github.com/casey/just) as a command runner. 4 | 5 | The following commands are available: 6 | 7 | 19 | 20 | 21 | ## Commands 22 | 23 | ```{code-block} shell 24 | :class: copy 25 | 26 | $ just --list 27 | ``` 28 | 37 | 38 | 39 | 61 | 62 | -------------------------------------------------------------------------------- /docs/development/releasing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../RELEASING.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ```{include} ../README.md 4 | :start-after: 5 | :end-before: 6 | ``` 7 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # django-simple-nav 2 | 3 | ```{include} ../README.md 4 | :start-after: 5 | :end-before: 6 | ``` 7 | 8 | ```{toctree} 9 | :hidden: 10 | :maxdepth: 3 11 | 12 | getting-started.md 13 | usage.md 14 | changelog.md 15 | ``` 16 | 17 | ```{toctree} 18 | :hidden: 19 | :maxdepth: 3 20 | :caption: Reference 21 | 22 | apidocs/index.rst 23 | ``` 24 | 25 | ```{toctree} 26 | :hidden: 27 | :maxdepth: 3 28 | :caption: API Reference 29 | 30 | apidocs/django_simple_nav/django_simple_nav.rst 31 | ``` 32 | 33 | ```{toctree} 34 | :hidden: 35 | :maxdepth: 3 36 | :caption: Development 37 | 38 | development/contributing.md 39 | development/just.md 40 | Releasing 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ```{include} ../README.md 4 | :start-after: 5 | :end-before: 6 | ``` 7 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-simple-nav/62565ad7827d52877592a6e1fe60e0501067546c/example/__init__.py -------------------------------------------------------------------------------- /example/demo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import secrets 5 | import sys 6 | from pathlib import Path 7 | 8 | from django.conf import settings 9 | from django.core.wsgi import get_wsgi_application 10 | from django.shortcuts import render 11 | from django.urls import path 12 | 13 | BASE_DIR = Path(__file__).parent 14 | 15 | settings.configure( 16 | ALLOWED_HOSTS="*", 17 | DATABASES={ 18 | "default": { 19 | "ENGINE": "django.db.backends.sqlite3", 20 | "NAME": BASE_DIR / "db.sqlite3", 21 | }, 22 | }, 23 | INSTALLED_APPS=[ 24 | "django.contrib.auth", 25 | "django.contrib.contenttypes", 26 | "django.contrib.sessions", 27 | "django_simple_nav", 28 | ], 29 | LOGGING={ 30 | "version": 1, 31 | "disable_existing_loggers": False, 32 | "formatters": { 33 | "verbose": { 34 | "format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s", 35 | }, 36 | }, 37 | "handlers": { 38 | "stdout": { 39 | "class": "logging.StreamHandler", 40 | "stream": sys.stdout, 41 | "formatter": "verbose", 42 | }, 43 | }, 44 | "loggers": { 45 | "django": { 46 | "handlers": ["stdout"], 47 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 48 | }, 49 | }, 50 | }, 51 | MIDDLEWARE=[ 52 | "django.contrib.sessions.middleware.SessionMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | ], 55 | ROOT_URLCONF=__name__, 56 | SECRET_KEY=secrets.token_hex(32), 57 | TEMPLATES=[ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [BASE_DIR / "templates"], 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.request", 64 | ], 65 | }, 66 | } 67 | ], 68 | ) 69 | 70 | 71 | def demo(request, template_name): 72 | return render(request, template_name) 73 | 74 | 75 | def permissions(request): 76 | perm = request.GET.get("permission", None) 77 | if perm and perm != "query_param_permission": 78 | request.user = type( 79 | "User", (), {"is_authenticated": True, "has_perm": lambda p: p == perm} 80 | ) 81 | if perm in ["is_staff", "is_superuser"]: 82 | setattr(request.user, perm, True) 83 | return demo(request, "permissions.html") 84 | 85 | 86 | urlpatterns = [ 87 | path("", demo, {"template_name": "base.html"}), 88 | path("basic/", demo, {"template_name": "basic.html"}), 89 | path("permissions/", permissions), 90 | path("extra-context/", demo, {"template_name": "extra_context.html"}), 91 | path("nested/", demo, {"template_name": "nested.html"}), 92 | path("tailwind/", demo, {"template_name": "tailwind.html"}), 93 | path("bootstrap4/", demo, {"template_name": "bootstrap4.html"}), 94 | path("bootstrap5/", demo, {"template_name": "bootstrap5.html"}), 95 | path("picocss/", demo, {"template_name": "picocss.html"}), 96 | ] 97 | 98 | app = get_wsgi_application() 99 | 100 | 101 | if __name__ == "__main__": 102 | from django.contrib.auth.models import Permission 103 | from django.core.management import call_command 104 | 105 | call_command("migrate") 106 | Permission.objects.update_or_create( 107 | codename="demo_permission", 108 | name="Demo Permission", 109 | content_type_id=1, 110 | ) 111 | call_command("runserver") 112 | -------------------------------------------------------------------------------- /example/navigation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.http import HttpRequest 4 | 5 | from django_simple_nav.nav import Nav 6 | from django_simple_nav.nav import NavGroup 7 | from django_simple_nav.nav import NavItem 8 | 9 | 10 | class ExampleListNav(Nav): 11 | template_name = "navs/example_list.html" 12 | items = [ 13 | NavItem(title="Basic", url="/basic/"), 14 | NavItem(title="Permissions", url="/permissions/"), 15 | NavItem(title="Extra Context", url="/extra-context/"), 16 | NavItem(title="Nested `Nav`", url="/nested/"), 17 | NavGroup( 18 | title="Frameworks", 19 | items=[ 20 | NavItem(title="Tailwind CSS", url="/tailwind/"), 21 | NavItem(title="Bootstrap 4", url="/bootstrap4/"), 22 | NavItem(title="Bootstrap 5", url="/bootstrap5/"), 23 | NavItem(title="Pico CSS", url="/picocss/"), 24 | ], 25 | ), 26 | ] 27 | 28 | 29 | class BasicNav(Nav): 30 | template_name = "navs/basic.html" 31 | items = [ 32 | NavItem(title="Home", url="/basic/"), 33 | NavItem(title="Link", url="#"), 34 | NavGroup( 35 | title="Group", 36 | items=[ 37 | NavItem(title="Link", url="#"), 38 | NavItem(title="Another link", url="#"), 39 | ], 40 | ), 41 | NavGroup( 42 | title="Group with Link", 43 | url="#", 44 | items=[ 45 | NavItem(title="Link", url="#"), 46 | NavItem(title="Another link", url="#"), 47 | ], 48 | ), 49 | ] 50 | 51 | 52 | def query_param_permission(request: HttpRequest) -> bool: 53 | return request.GET.get("permission", None) == "query_param_permission" 54 | 55 | 56 | class PermissionsNav(Nav): 57 | template_name = "navs/basic.html" 58 | items = [ 59 | NavItem(title="Everyone can see this link", url="#"), 60 | NavItem( 61 | title="You are authenticated", 62 | url="#", 63 | permissions=["is_authenticated"], 64 | ), 65 | NavItem( 66 | title="You are a staff member", 67 | url="#", 68 | permissions=["is_staff"], 69 | ), 70 | NavItem( 71 | title="You are a superuser", 72 | url="#", 73 | permissions=["is_superuser"], 74 | ), 75 | NavItem( 76 | title="You have the `demo_permission`", 77 | url="#", 78 | permissions=["demo_permission"], 79 | ), 80 | NavItem( 81 | title="This item depends on a permission in the query params of the URL", 82 | url="#", 83 | permissions=[query_param_permission], 84 | ), 85 | ] 86 | 87 | 88 | class SetPermissionsNav(Nav): 89 | template_name = "navs/basic.html" 90 | items = [ 91 | NavItem(title="AnonymousUser", url="/permissions/"), 92 | NavItem( 93 | title="`is_authenticated`", 94 | url="/permissions/?permission=is_authenticated", 95 | ), 96 | NavItem( 97 | title="`is_staff`", 98 | url="/permissions/?permission=is_staff", 99 | ), 100 | NavItem( 101 | title="`is_superuser`", 102 | url="/permissions/?permission=is_superuser", 103 | ), 104 | NavItem( 105 | title="Permission `demo_permission`", 106 | url="/permissions/?permission=demo_permission", 107 | ), 108 | NavItem( 109 | title="Callable permission", 110 | url="/permissions/?permission=query_param_permission", 111 | ), 112 | ] 113 | 114 | 115 | class ExtraContextNav(Nav): 116 | template_name = "navs/extra_context.html" 117 | items = [ 118 | NavItem(title="Normal Link", url="#"), 119 | NavItem(title="Has Extra Context", url="#", extra_context={"foo": "bar"}), 120 | ] 121 | 122 | 123 | class NestedNav(BasicNav): 124 | template_name = "navs/nested.html" 125 | 126 | 127 | class TailwindMainNav(Nav): 128 | template_name = "navs/tailwind_main.html" 129 | items = [ 130 | NavItem(title="Dashboard", url="/tailwind/"), 131 | NavItem(title="Team", url="#"), 132 | NavItem(title="Projects", url="#"), 133 | NavItem(title="Calendar", url="#"), 134 | ] 135 | 136 | 137 | class TailwindProfileNav(Nav): 138 | template_name = "navs/tailwind_profile.html" 139 | items = [ 140 | NavItem(title="Your Profile", url="#"), 141 | NavItem(title="Settings", url="#"), 142 | NavItem(title="Sign out", url="#"), 143 | ] 144 | 145 | 146 | class Bootstrap4Nav(Nav): 147 | template_name = "navs/bootstrap4.html" 148 | items = [ 149 | NavItem(title="Home", url="/bootstrap4/"), 150 | NavItem(title="Link", url="#"), 151 | NavItem(title="Disabled", url="#", extra_context={"disabled": True}), 152 | NavGroup( 153 | title="Dropdown", 154 | items=[ 155 | NavItem(title="Action", url="#"), 156 | NavItem(title="Another action", url="#"), 157 | NavItem(title="Something else here", url="#"), 158 | ], 159 | ), 160 | ] 161 | 162 | 163 | class Bootstrap5Nav(Bootstrap4Nav): 164 | template_name = "navs/bootstrap5.html" 165 | 166 | 167 | class PicoCSSNav(Nav): 168 | template_name = "navs/picocss.html" 169 | items = [ 170 | NavItem(title="Services", url="/picocss/"), 171 | NavGroup( 172 | title="Account", 173 | items=[ 174 | NavItem(title="Profile", url="#"), 175 | NavItem(title="Settings", url="#"), 176 | NavItem(title="Security", url="#"), 177 | NavItem(title="Logout", url="#"), 178 | ], 179 | ), 180 | ] 181 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | {# djlint:off D018,H021,H031 #} 2 | {% load django_simple_nav %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block title %} 12 | django-simple-nav Demo 13 | {% endblock title %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% block extra_head %} 21 | 22 | 29 | 33 | 37 | 41 | 45 | {% endblock extra_head %} 46 | 47 | 48 | 49 | 50 | {% block nav %} 51 | {% endblock nav %} 52 | 53 |
54 | 55 | {% block content %} 56 |
60 | {% if request.path != "/" %} 61 | 65 | 71 | 72 | 73 | Back to index 74 | 75 | {% endif %} 76 |

77 | django-simple-nav Examples 78 |

79 | {% django_simple_nav "navigation.ExampleListNav" %} 80 |
81 | {% endblock content %} 82 | 83 |
84 | 85 | 86 | -------------------------------------------------------------------------------- /example/templates/basic.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 11 | {% endblock extra_head %} 12 | 13 | {% block nav %} 14 | {% django_simple_nav "navigation.BasicNav" %} 15 | {% endblock nav %} 16 | -------------------------------------------------------------------------------- /example/templates/bootstrap4.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 10 | 13 | 16 | 19 | {% endblock extra_head %} 20 | 21 | {% block nav %} 22 | {% django_simple_nav "navigation.Bootstrap4Nav" %} 23 | {% endblock nav %} 24 | -------------------------------------------------------------------------------- /example/templates/bootstrap5.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 10 | 13 | {% endblock extra_head %} 14 | 15 | {% block nav %} 16 | {% django_simple_nav "navigation.Bootstrap5Nav" "navs/bootstrap5.html" %} 17 | {% endblock nav %} 18 | -------------------------------------------------------------------------------- /example/templates/extra_context.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 11 | {% endblock extra_head %} 12 | 13 | {% block nav %} 14 | {% django_simple_nav "navigation.ExtraContextNav" %} 15 | {% endblock nav %} 16 | -------------------------------------------------------------------------------- /example/templates/navs/basic.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /example/templates/navs/bootstrap4.html: -------------------------------------------------------------------------------- 1 | 45 | -------------------------------------------------------------------------------- /example/templates/navs/bootstrap5.html: -------------------------------------------------------------------------------- 1 | 51 | -------------------------------------------------------------------------------- /example/templates/navs/example_list.html: -------------------------------------------------------------------------------- 1 |
    2 | {% for item in items %} 3 |
  • 4 | {% if item.url %} 5 | {{ item.title }} 8 | {% else %} 9 | {{ item.title }} 10 | {% endif %} 11 | {% if item.items %} 12 |
      13 | {% for subitem in item.items %} 14 |
    • 15 | {{ subitem.title }} 18 |
    • 19 | {% endfor %} 20 |
    21 | {% endif %} 22 |
  • 23 | {% endfor %} 24 |
25 | -------------------------------------------------------------------------------- /example/templates/navs/extra_context.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /example/templates/navs/nested.html: -------------------------------------------------------------------------------- 1 | {% load django_simple_nav %} 2 | 3 | 30 | -------------------------------------------------------------------------------- /example/templates/navs/picocss.html: -------------------------------------------------------------------------------- 1 | 34 | -------------------------------------------------------------------------------- /example/templates/navs/tailwind_main.html: -------------------------------------------------------------------------------- 1 | {# djlint:off H006 #} 2 | {% load django_simple_nav %} 3 | 4 | 122 | -------------------------------------------------------------------------------- /example/templates/navs/tailwind_profile.html: -------------------------------------------------------------------------------- 1 | {% for item in items %} 2 | {{ item.title }} 7 | {% endfor %} 8 | -------------------------------------------------------------------------------- /example/templates/nested.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 11 | {% endblock extra_head %} 12 | 13 | {% block nav %} 14 | {% django_simple_nav "navigation.NestedNav" %} 15 | {% endblock nav %} 16 | -------------------------------------------------------------------------------- /example/templates/permissions.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 11 | {% endblock extra_head %} 12 | 13 | {% block nav %} 14 | {% django_simple_nav "navigation.PermissionsNav" %} 15 |

Select a permission to see the nav for it

16 | {% django_simple_nav "navigation.SetPermissionsNav" %} 17 | {% endblock nav %} 18 | -------------------------------------------------------------------------------- /example/templates/picocss.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block extra_head %} 6 | 8 | {% endblock extra_head %} 9 | 10 | {% block nav %} 11 | {% django_simple_nav "navigation.PicoCSSNav" "navs/picocss.html" %} 12 | {% endblock nav %} 13 | -------------------------------------------------------------------------------- /example/templates/tailwind.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load django_simple_nav %} 4 | 5 | {% block nav %} 6 | {% django_simple_nav "navigation.TailwindMainNav" %} 7 | {% endblock nav %} 8 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from pathlib import Path 6 | 7 | import nox 8 | 9 | nox.options.default_venv_backend = "uv|virtualenv" 10 | nox.options.reuse_existing_virtualenvs = True 11 | 12 | PY39 = "3.9" 13 | PY310 = "3.10" 14 | PY311 = "3.11" 15 | PY312 = "3.12" 16 | PY313 = "3.13" 17 | PY_VERSIONS = [PY39, PY310, PY311, PY312, PY313] 18 | PY_DEFAULT = PY_VERSIONS[0] 19 | PY_LATEST = PY_VERSIONS[-1] 20 | 21 | DJ42 = "4.2" 22 | DJ50 = "5.0" 23 | DJ51 = "5.1" 24 | DJ52 = "5.2a1" 25 | DJMAIN = "main" 26 | DJMAIN_MIN_PY = PY312 27 | DJ_VERSIONS = [DJ42, DJ50, DJ51, DJMAIN] 28 | DJ_LTS = [ 29 | version for version in DJ_VERSIONS if version.endswith(".2") and version != DJMAIN 30 | ] 31 | DJ_DEFAULT = DJ_LTS[0] 32 | DJ_LATEST = DJ_VERSIONS[-2] 33 | 34 | 35 | def version(ver: str) -> tuple[int, ...]: 36 | """Convert a string version to a tuple of ints, e.g. "3.10" -> (3, 10)""" 37 | return tuple(map(int, ver.split("."))) 38 | 39 | 40 | def should_skip(python: str, django: str) -> bool: 41 | """Return True if the test should be skipped""" 42 | 43 | if django == DJMAIN and version(python) < version(DJMAIN_MIN_PY): 44 | # Django main requires Python 3.12+ 45 | return True 46 | 47 | if django == DJ52 and version(python) < version(PY310): 48 | # Django 5.1 requires Python 3.10+ 49 | return True 50 | 51 | if django == DJ51 and version(python) < version(PY310): 52 | # Django 5.1 requires Python 3.10+ 53 | return True 54 | 55 | if django == DJ50 and version(python) < version(PY310): 56 | # Django 5.0 requires Python 3.10+ 57 | return True 58 | 59 | return False 60 | 61 | 62 | @nox.session 63 | def test(session): 64 | session.notify(f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')") 65 | 66 | 67 | @nox.session 68 | @nox.parametrize( 69 | "python,django", 70 | [ 71 | (python, django) 72 | for python in PY_VERSIONS 73 | for django in DJ_VERSIONS 74 | if not should_skip(python, django) 75 | ], 76 | ) 77 | def tests(session, django): 78 | session.run_install( 79 | "uv", 80 | "sync", 81 | "--extra", 82 | "tests", 83 | "--frozen", 84 | "--inexact", 85 | "--no-install-package", 86 | "django", 87 | "--python", 88 | session.python, 89 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 90 | ) 91 | 92 | if django == DJMAIN: 93 | session.install( 94 | "django @ https://github.com/django/django/archive/refs/heads/main.zip" 95 | ) 96 | else: 97 | session.install(f"django=={django}") 98 | 99 | command = ["python", "-m", "pytest"] 100 | if session.posargs and all(arg for arg in session.posargs): 101 | command.append(*session.posargs) 102 | session.run(*command) 103 | 104 | 105 | @nox.session 106 | def coverage(session): 107 | session.run_install( 108 | "uv", 109 | "sync", 110 | "--extra", 111 | "tests", 112 | "--frozen", 113 | "--python", 114 | PY_LATEST, 115 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 116 | ) 117 | 118 | try: 119 | session.run("python", "-m", "pytest", "--cov", "--cov-report=") 120 | finally: 121 | report_cmd = ["python", "-m", "coverage", "report"] 122 | session.run(*report_cmd) 123 | 124 | if summary := os.getenv("GITHUB_STEP_SUMMARY"): 125 | report_cmd.extend(["--skip-covered", "--skip-empty", "--format=markdown"]) 126 | 127 | with Path(summary).open("a") as output_buffer: 128 | output_buffer.write("") 129 | output_buffer.write("### Coverage\n\n") 130 | output_buffer.flush() 131 | session.run(*report_cmd, stdout=output_buffer) 132 | else: 133 | session.run( 134 | "python", "-m", "coverage", "html", "--skip-covered", "--skip-empty" 135 | ) 136 | 137 | 138 | @nox.session 139 | def types(session): 140 | session.run_install( 141 | "uv", 142 | "sync", 143 | "--extra", 144 | "types", 145 | "--frozen", 146 | "--python", 147 | PY_LATEST, 148 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 149 | ) 150 | command = ["python", "-m", "mypy", "."] 151 | if session.posargs and all(arg for arg in session.posargs): 152 | command.append(*session.posargs) 153 | session.run(*command) 154 | 155 | 156 | @nox.session 157 | def demo(session): 158 | session.run_install( 159 | "uv", 160 | "sync", 161 | "--extra", 162 | "types", 163 | "--frozen", 164 | "--python", 165 | PY_DEFAULT, 166 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 167 | ) 168 | 169 | command = ["python", "example/demo.py", "runserver"] 170 | if session.posargs and all(arg for arg in session.posargs): 171 | command.append(*session.posargs) 172 | else: 173 | command.append("localhost:8000") 174 | session.run(*command) 175 | 176 | 177 | @nox.session 178 | def gha_matrix(session): 179 | sessions = session.run("nox", "-l", "--json", silent=True) 180 | matrix = { 181 | "include": [ 182 | { 183 | "python-version": session["python"], 184 | "django-version": session["call_spec"]["django"], 185 | } 186 | for session in json.loads(sessions) 187 | if session["name"] == "tests" 188 | ] 189 | } 190 | with Path(os.environ["GITHUB_OUTPUT"]).open("a") as fh: 191 | print(f"matrix={matrix}", file=fh) 192 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling"] 4 | 5 | [project] 6 | authors = [ 7 | {name = "Josh Thomas", email = "josh@joshthomas.dev"} 8 | ] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Framework :: Django", 12 | "Framework :: Django :: 4.2", 13 | "Framework :: Django :: 5.0", 14 | "Framework :: Django :: 5.1", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: Implementation :: CPython" 26 | ] 27 | dependencies = [ 28 | "django>=4.2" 29 | ] 30 | description = "A simple, flexible, and extensible navigation menu for Django." 31 | dynamic = ["version"] 32 | keywords = [] 33 | license = {file = "LICENSE"} 34 | name = "django-simple-nav" 35 | readme = "README.md" 36 | requires-python = ">=3.9" 37 | 38 | [project.optional-dependencies] 39 | docs = [ 40 | "cogapp>=3.4.1", 41 | "furo>=2024.8.6", 42 | "myst-parser>=3.0.1", 43 | "sphinx>=7.1.2", 44 | "sphinx-autobuild>=2021.3.14", 45 | "sphinx-autodoc2>=0.5.0", 46 | "sphinx-copybutton>=0.5.2", 47 | "sphinx-inline-tabs>=2023.4.21" 48 | ] 49 | tests = [ 50 | "faker>=30.3.0", 51 | "model-bakery>=1.20.0", 52 | "pytest>=8.3.3", 53 | "pytest-cov>=5.0.0", 54 | "pytest-django>=4.9.0", 55 | "pytest-randomly>=3.15.0", 56 | "pytest-xdist>=3.6.1" 57 | ] 58 | types = [ 59 | "django-stubs>=5.1.0", 60 | "django-stubs-ext>=5.1.0", 61 | "mypy>=1.11.2" 62 | ] 63 | 64 | [project.urls] 65 | Documentation = "https://django-simple-nav.westervelt.dev/" 66 | Issues = "https://github.com/westerveltco/django-simple-nav/issues" 67 | Source = "https://github.com/westerveltco/django-simple-nav" 68 | 69 | [tool.basedpyright] 70 | exclude = [ 71 | "**/node_modules", 72 | "**/__pycache__" 73 | ] 74 | include = ["src"] 75 | 76 | [tool.bumpver] 77 | commit = true 78 | commit_message = ":bookmark: bump version {old_version} -> {new_version}" 79 | current_version = "0.12.0" 80 | push = false # set to false for CI 81 | tag = false 82 | version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" 83 | 84 | [tool.bumpver.file_patterns] 85 | ".copier/package.yml" = [ 86 | 'current_version: {version}' 87 | ] 88 | "src/django_simple_nav/__init__.py" = [ 89 | '__version__ = "{version}"' 90 | ] 91 | "tests/test_version.py" = [ 92 | 'assert __version__ == "{version}"' 93 | ] 94 | 95 | [tool.coverage.paths] 96 | source = ["src"] 97 | 98 | [tool.coverage.report] 99 | exclude_lines = [ 100 | "pragma: no cover", 101 | "if DEBUG:", 102 | "if not DEBUG:", 103 | "if settings.DEBUG:", 104 | "if TYPE_CHECKING:", 105 | 'def __str__\(self\)\s?\-?\>?\s?\w*\:' 106 | ] 107 | fail_under = 75 108 | 109 | [tool.coverage.run] 110 | omit = [ 111 | "src/django_simple_nav/migrations/*", 112 | "tests/*" 113 | ] 114 | source = ["django_simple_nav"] 115 | 116 | [tool.django-stubs] 117 | django_settings_module = "tests.settings" 118 | strict_settings = false 119 | 120 | [tool.djlint] 121 | blank_line_after_tag = "endblock,endpartialdef,extends,load" 122 | blank_line_before_tag = "block,partialdef" 123 | custom_blocks = "partialdef" 124 | ignore = "H031" # Don't require `meta` tag keywords 125 | indent = 2 126 | profile = "django" 127 | 128 | [tool.hatch.build] 129 | exclude = [ 130 | ".*", 131 | "Justfile" 132 | ] 133 | 134 | [tool.hatch.build.targets.wheel] 135 | packages = ["src/django_simple_nav"] 136 | 137 | [tool.hatch.version] 138 | path = "src/django_simple_nav/__init__.py" 139 | 140 | [tool.mypy] 141 | check_untyped_defs = true 142 | exclude = [ 143 | "docs", 144 | "migrations", 145 | "tests", 146 | "venv", 147 | ".venv" 148 | ] 149 | mypy_path = "src/" 150 | no_implicit_optional = true 151 | plugins = [ 152 | "mypy_django_plugin.main" 153 | ] 154 | warn_redundant_casts = true 155 | warn_unused_configs = true 156 | warn_unused_ignores = true 157 | 158 | [[tool.mypy.overrides]] 159 | ignore_errors = true 160 | ignore_missing_imports = true 161 | module = [ 162 | "*.migrations.*", 163 | "docs.*", 164 | "tests.*" 165 | ] 166 | 167 | [tool.mypy_django_plugin] 168 | ignore_missing_model_attributes = true 169 | 170 | [tool.pytest.ini_options] 171 | addopts = "--create-db -n auto --dist loadfile --doctest-modules" 172 | django_find_project = false 173 | norecursedirs = ".* bin build dist *.egg example htmlcov logs node_modules templates venv" 174 | python_files = "tests.py test_*.py *_tests.py" 175 | pythonpath = "src" 176 | testpaths = ["tests"] 177 | 178 | [tool.ruff] 179 | # Exclude a variety of commonly ignored directories. 180 | exclude = [ 181 | ".bzr", 182 | ".direnv", 183 | ".eggs", 184 | ".git", 185 | ".github", 186 | ".hg", 187 | ".mypy_cache", 188 | ".ruff_cache", 189 | ".svn", 190 | ".tox", 191 | ".venv", 192 | "__pypackages__", 193 | "_build", 194 | "build", 195 | "dist", 196 | "migrations", 197 | "node_modules", 198 | "venv" 199 | ] 200 | extend-include = ["*.pyi?"] 201 | indent-width = 4 202 | # Same as Black. 203 | line-length = 88 204 | # Assume Python >3.9 205 | target-version = "py39" 206 | 207 | [tool.ruff.format] 208 | # Like Black, indent with spaces, rather than tabs. 209 | indent-style = "space" 210 | # Like Black, automatically detect the appropriate line ending. 211 | line-ending = "auto" 212 | # Like Black, use double quotes for strings. 213 | quote-style = "double" 214 | 215 | [tool.ruff.lint] 216 | # Allow unused variables when underscore-prefixed. 217 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 218 | # Allow autofix for all enabled rules (when `--fix`) is provided. 219 | fixable = ["A", "B", "C", "D", "E", "F", "I"] 220 | ignore = ["E501", "E741"] # temporary 221 | select = [ 222 | "B", # flake8-bugbear 223 | "E", # Pycodestyle 224 | "F", # Pyflakes 225 | "I", # isort 226 | "UP" # pyupgrade 227 | ] 228 | unfixable = [] 229 | 230 | [tool.ruff.lint.isort] 231 | force-single-line = true 232 | known-first-party = ["django_simple_nav"] 233 | required-imports = ["from __future__ import annotations"] 234 | 235 | [tool.ruff.lint.per-file-ignores] 236 | # Tests can use magic values, assertions, and relative imports 237 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 238 | 239 | [tool.ruff.lint.pyupgrade] 240 | # Preserve types, even if a file imports `from __future__ import annotations`. 241 | keep-runtime-typing = true 242 | 243 | [tool.uv] 244 | dev-dependencies = [ 245 | "copier>=9.3.1", 246 | "copier-templates-extensions>=0.3.0", 247 | "django-stubs>=5.1.0", 248 | "django-stubs-ext>=5.1.0", 249 | "nox[uv]>=2024.10.9", 250 | "ruff>=0.6.9" 251 | ] 252 | -------------------------------------------------------------------------------- /src/django_simple_nav/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __version__ = "0.12.0" 4 | -------------------------------------------------------------------------------- /src/django_simple_nav/_templates.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.template import engines 7 | from django.template.backends.base import BaseEngine 8 | 9 | from django_simple_nav.conf import app_settings 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_template_engine(using: str | None = None) -> BaseEngine: 15 | if template_backend := app_settings.TEMPLATE_BACKEND: 16 | # https://github.com/django/django/blob/082fe2b5a83571dec4aa97580af0fe8cf2a5214e/django/template/utils.py#L33-L42 17 | try: 18 | backend_alias = template_backend.rsplit(".", 2)[-2] 19 | except Exception as err: 20 | msg = f"Invalid `TEMPLATE_BACKEND` for a template engine: {app_settings.TEMPLATE_BACKEND}. Check your `DJANGO_SIMPLE_NAV['TEMPLATE_BACKEND']` setting." 21 | raise ImproperlyConfigured(msg) from err 22 | 23 | engine = engines[backend_alias] 24 | else: 25 | all_engines = engines.all() 26 | num_of_engines = len(all_engines) 27 | 28 | if num_of_engines == 0: 29 | msg = "No `BACKEND` found for a template engine. Please configure at least one in your `TEMPLATES` setting or set `DJANGO_SIMPLE_NAV['TEMPLATE_BACKEND']`." 30 | raise ImproperlyConfigured(msg) 31 | 32 | if num_of_engines > 1: 33 | msg = "Multiple `BACKEND` defined for a template engine. Will proceed with first defined in list, otherwise set `DJANGO_SIMPLE_NAV['TEMPLATE_BACKEND']` to specify which one to use." 34 | logger.warning(msg) 35 | 36 | # https://github.com/django/django/blob/082fe2b5a83571dec4aa97580af0fe8cf2a5214e/django/template/loader.py#L65-L66 37 | engine = all_engines[0] if using is None else engines[using] 38 | 39 | return engine 40 | -------------------------------------------------------------------------------- /src/django_simple_nav/_typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import Protocol 5 | 6 | from django.http import HttpRequest 7 | from django.template.context import Context 8 | from django.utils.safestring import SafeString 9 | 10 | if sys.version_info >= (3, 12): 11 | from typing import override as typing_override 12 | else: # pragma: no cover 13 | from typing_extensions import ( 14 | override as typing_override, # pyright: ignore[reportUnreachable] 15 | ) 16 | 17 | override = typing_override 18 | 19 | 20 | # https://github.com/typeddjango/django-stubs/blob/b6e8ea9b4279ece87d14e38226c265e4f0aadccd/django-stubs/template/backends/base.pyi#L22-L28 21 | class EngineTemplate(Protocol): 22 | def render( 23 | self, 24 | context: Context | dict[str, object] | None = ..., 25 | request: HttpRequest | None = ..., 26 | ) -> SafeString: ... 27 | -------------------------------------------------------------------------------- /src/django_simple_nav/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class DjangoSimpleNavConfig(AppConfig): 7 | name = "django_simple_nav" 8 | -------------------------------------------------------------------------------- /src/django_simple_nav/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from django.conf import settings 6 | 7 | from ._typing import override 8 | 9 | DJANGO_SIMPLE_NAV_SETTINGS_NAME = "DJANGO_SIMPLE_NAV" 10 | 11 | 12 | @dataclass(frozen=True) 13 | class AppSettings: 14 | TEMPLATE_BACKEND: str | None = None 15 | 16 | @override 17 | def __getattribute__(self, __name: str) -> object: 18 | user_settings = getattr(settings, DJANGO_SIMPLE_NAV_SETTINGS_NAME, {}) 19 | return user_settings.get(__name, super().__getattribute__(__name)) # pyright: ignore[reportAny] 20 | 21 | 22 | app_settings = AppSettings() 23 | -------------------------------------------------------------------------------- /src/django_simple_nav/nav.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from dataclasses import dataclass 5 | from dataclasses import field 6 | from typing import Callable 7 | from typing import cast 8 | from urllib.parse import parse_qs 9 | from urllib.parse import urlparse 10 | from urllib.parse import urlunparse 11 | 12 | from django.apps import apps 13 | from django.conf import settings 14 | from django.contrib.auth.models import AbstractUser 15 | from django.core.exceptions import ImproperlyConfigured 16 | from django.http import HttpRequest 17 | from django.template.loader import get_template 18 | from django.urls import reverse 19 | from django.urls.exceptions import NoReverseMatch 20 | from django.utils.functional import Promise 21 | from django.utils.safestring import mark_safe 22 | 23 | from ._templates import get_template_engine 24 | from ._typing import EngineTemplate 25 | from ._typing import override 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | @dataclass(frozen=True) 31 | class Nav: 32 | template_name: str | None = field(init=False, default=None) 33 | items: list[NavGroup | NavItem] | None = field(init=False, default=None) 34 | 35 | def render(self, request: HttpRequest, template_name: str | None = None) -> str: 36 | context = self.get_context_data(request) 37 | template = self.get_template(template_name) 38 | if isinstance(template, str): 39 | engine = get_template_engine() 40 | template = engine.from_string(template) 41 | return template.render(context, request) 42 | 43 | def get_context_data(self, request: HttpRequest) -> dict[str, object]: 44 | items = self.get_items(request) 45 | return { 46 | "items": [item.get_context_data(request) for item in items], 47 | } 48 | 49 | def get_items(self, request: HttpRequest) -> list[NavGroup | NavItem]: 50 | if self.items is not None: 51 | return [item for item in self.items if item.check_permissions(request)] 52 | 53 | msg = f"{self.__class__!r} must define 'items' or override 'get_items()'" 54 | raise ImproperlyConfigured(msg) 55 | 56 | def get_template(self, template_name: str | None = None) -> EngineTemplate | str: 57 | return get_template(template_name=template_name or self.get_template_name()) 58 | 59 | def get_template_name(self) -> str: 60 | if self.template_name is not None: 61 | return self.template_name 62 | 63 | msg = f"{self.__class__!r} must define 'template_name' or override 'get_template_name()'" 64 | raise ImproperlyConfigured(msg) 65 | 66 | 67 | @dataclass(frozen=True) 68 | class NavItem: 69 | title: str 70 | url: str | Callable[..., str] | Promise | None = None 71 | permissions: list[str | Callable[[HttpRequest], bool]] = field(default_factory=list) 72 | extra_context: dict[str, object] = field(default_factory=dict) 73 | 74 | def get_context_data(self, request: HttpRequest) -> dict[str, object]: 75 | context = { 76 | "title": self.get_title(), 77 | "url": self.get_url(), 78 | "active": self.get_active(request), 79 | "items": self.get_items(request), 80 | } 81 | # filter out any items in `extra_context` that may be shadowing the 82 | # above `context` dict 83 | extra_context = { 84 | key: value 85 | for key, value in self.extra_context.copy().items() 86 | if context.get(key) is None 87 | } 88 | return { 89 | **context, 90 | **extra_context, 91 | } 92 | 93 | def get_title(self) -> str: 94 | return mark_safe(self.title) 95 | 96 | def get_url(self) -> str: 97 | url: str | None 98 | 99 | if isinstance(self.url, Promise): 100 | # django.urls.base.reverse_lazy 101 | url = str(self.url) 102 | elif callable(self.url): 103 | # django.urls.base.reverse (or some other basic callable) 104 | url = self.url() 105 | else: 106 | try: 107 | url = reverse(self.url) 108 | except NoReverseMatch: 109 | url = self.url 110 | 111 | if url is not None: 112 | parsed_url = urlparse(url) 113 | path = parsed_url.path 114 | if settings.APPEND_SLASH and not path.endswith("/"): 115 | path += "/" 116 | url = urlunparse( 117 | ( 118 | parsed_url.scheme, 119 | parsed_url.netloc, 120 | path, 121 | parsed_url.params, 122 | parsed_url.query, 123 | parsed_url.fragment, 124 | ) 125 | ) 126 | return url 127 | 128 | msg = f"{self.__class__!r} must define 'url' or override 'get_url()'" 129 | raise ImproperlyConfigured(msg) 130 | 131 | def get_active(self, request: HttpRequest) -> bool: 132 | try: 133 | url = self.get_url() 134 | except ImproperlyConfigured: 135 | url = None 136 | 137 | if not url: 138 | return False 139 | 140 | parsed_url = urlparse(url) 141 | parsed_request = urlparse(request.build_absolute_uri()) 142 | 143 | if (parsed_url.scheme and (parsed_url.scheme != parsed_request.scheme)) or ( 144 | parsed_url.netloc and (parsed_url.netloc != parsed_request.netloc) 145 | ): 146 | return False 147 | 148 | url_path = parsed_url.path 149 | request_path = parsed_request.path 150 | 151 | if settings.APPEND_SLASH: 152 | url_path = url_path.rstrip("/") + "/" 153 | request_path = request_path.rstrip("/") + "/" 154 | 155 | url_query = parse_qs(parsed_url.query) 156 | request_query = parse_qs(parsed_request.query) 157 | 158 | return url_path == request_path and url_query == request_query 159 | 160 | def get_items(self, request: HttpRequest) -> list[NavGroup | NavItem] | None: 161 | # this needs to be set to shadow the built-in `items()` of the dict 162 | # returned by this method for `NavItem`. otherwise when looping through 163 | # the items in a `Nav`, calling `{% if item.items %}` will resolve to `True`. 164 | # this is a consequence of getting rid of `RenderedNavItem` from earlier versions 165 | # of `django-simple-nav` which was done to slightly simplfy the library. though 166 | # given this hack, maybe that wasn't really worth it? idk... i guess it could 167 | # have been called `children` or `subnav`, but i started with `items` and 168 | # i'd rather have API consistency and this hack. 169 | return None 170 | 171 | def check_permissions(self, request: HttpRequest) -> bool: 172 | if not apps.is_installed("django.contrib.auth"): 173 | logger.warning( 174 | "The 'django.contrib.auth' app is not installed, so permissions will not be checked." 175 | ) 176 | return True 177 | 178 | if not hasattr(request, "user"): 179 | # if no user attached to request, we assume that the user is not authenticated 180 | # and we should hide if *any* permissions are set 181 | return not self.permissions 182 | 183 | if not self.permissions: 184 | return True 185 | 186 | # explicitly cast to AbstractUser to make static type checkers happy 187 | # `django-stubs` types `request.user` as `django.contrib.auth.base_user.AbstractBaseUser` 188 | # as opposed to `django.contrib.auth.models.AbstractUser` or `django.contrib.auth.models.User` 189 | # so any type checkers will complain if this is not casted 190 | user = cast(AbstractUser, request.user) 191 | 192 | permission_checks: list[bool] = [] 193 | 194 | for idx, perm in enumerate(self.permissions): 195 | has_perm = False 196 | 197 | if getattr(user, "is_superuser", False): 198 | permission_checks.append(True) 199 | break 200 | elif callable(perm): 201 | has_perm = perm(request) 202 | elif perm in ["is_authenticated", "is_staff"]: 203 | has_perm = getattr(user, perm, False) 204 | else: 205 | has_perm = user.has_perm(perm) 206 | 207 | permission_checks.append(has_perm) 208 | 209 | if not idx == len(self.permissions) - 1: 210 | continue 211 | 212 | return all(permission_checks) 213 | 214 | 215 | @dataclass(frozen=True) 216 | class NavGroup(NavItem): 217 | items: list[NavGroup | NavItem] = field(default_factory=list) 218 | 219 | @override 220 | def get_context_data(self, request: HttpRequest) -> dict[str, object]: 221 | context = super().get_context_data(request) 222 | 223 | items = self.get_items(request) 224 | context["items"] = [item.get_context_data(request) for item in items] 225 | 226 | return context 227 | 228 | @override 229 | def get_items(self, request: HttpRequest) -> list[NavGroup | NavItem]: 230 | return [item for item in self.items if item.check_permissions(request)] 231 | 232 | @override 233 | def get_url(self) -> str: 234 | try: 235 | url = super().get_url() 236 | except ImproperlyConfigured: 237 | return "" 238 | return url 239 | 240 | @override 241 | def get_active(self, request: HttpRequest) -> bool: 242 | if super().get_active(request): 243 | return True 244 | items = self.get_items(request) 245 | return any(item.get_active(request) for item in items) 246 | 247 | @override 248 | def check_permissions(self, request: HttpRequest) -> bool: 249 | has_perm = super().check_permissions(request) 250 | 251 | sub_items = [ 252 | sub_item 253 | for sub_item in self.items 254 | if sub_item.check_permissions(request) is not False 255 | ] 256 | if not sub_items and not self.url: 257 | has_perm = False 258 | 259 | return has_perm 260 | -------------------------------------------------------------------------------- /src/django_simple_nav/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-simple-nav/62565ad7827d52877592a6e1fe60e0501067546c/src/django_simple_nav/py.typed -------------------------------------------------------------------------------- /src/django_simple_nav/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-simple-nav/62565ad7827d52877592a6e1fe60e0501067546c/src/django_simple_nav/templatetags/__init__.py -------------------------------------------------------------------------------- /src/django_simple_nav/templatetags/django_simple_nav.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django import template 4 | from django.http import HttpRequest 5 | from django.template.base import Parser 6 | from django.template.base import Token 7 | from django.template.context import Context 8 | from django.utils.module_loading import import_string 9 | 10 | from django_simple_nav._typing import override 11 | from django_simple_nav.nav import Nav 12 | 13 | register = template.Library() 14 | 15 | 16 | @register.tag(name="django_simple_nav") 17 | def do_django_simple_nav(parser: Parser, token: Token) -> DjangoSimpleNavNode: 18 | try: 19 | args = token.split_contents()[1:] 20 | if len(args) == 0: 21 | raise ValueError 22 | except ValueError as err: 23 | raise template.TemplateSyntaxError( 24 | f"{token.split_contents()[0]} tag requires arguments" 25 | ) from err 26 | 27 | nav = args[0] 28 | template_name = args[1] if len(args) > 1 else None 29 | 30 | return DjangoSimpleNavNode(nav, template_name) 31 | 32 | 33 | class DjangoSimpleNavNode(template.Node): 34 | def __init__(self, nav: str, template_name: str | None) -> None: 35 | self.nav = template.Variable(nav) 36 | self.template_name = template.Variable(template_name) if template_name else None 37 | 38 | @override 39 | def render(self, context: Context) -> str: 40 | nav = self.get_nav(context) 41 | template_name = self.get_template_name(context) 42 | request = self.get_request(context) 43 | 44 | return nav.render(request, template_name) 45 | 46 | def get_nav(self, context: Context) -> Nav: 47 | try: 48 | nav: str | Nav = self.nav.resolve(context) 49 | except template.VariableDoesNotExist as err: 50 | raise template.TemplateSyntaxError( 51 | f"Variable does not exist: {err}" 52 | ) from err 53 | 54 | if isinstance(nav, str): 55 | try: 56 | nav_instance: object = import_string(nav)() 57 | except ImportError as err: 58 | raise template.TemplateSyntaxError( 59 | f"Failed to import from dotted string: {nav}" 60 | ) from err 61 | else: 62 | nav_instance = nav 63 | 64 | if not isinstance(nav_instance, Nav): 65 | raise template.TemplateSyntaxError( 66 | f"Not a valid `Nav` instance: {nav_instance}" 67 | ) 68 | 69 | return nav_instance 70 | 71 | def get_template_name(self, context: Context) -> str | None: 72 | try: 73 | template_name = ( 74 | self.template_name.resolve(context) if self.template_name else None 75 | ) 76 | except template.VariableDoesNotExist as err: 77 | raise template.TemplateSyntaxError( 78 | f"Variable does not exist: {err}" 79 | ) from err 80 | 81 | return template_name 82 | 83 | def get_request(self, context: Context) -> HttpRequest: 84 | request = context.get("request", None) 85 | 86 | if not request: 87 | raise template.TemplateSyntaxError( 88 | f"`request` not found in template context: {context}" 89 | ) 90 | 91 | if not isinstance(request, HttpRequest): 92 | raise template.TemplateSyntaxError( 93 | f"`request` not a valid `HttpRequest`: {request}" 94 | ) 95 | 96 | return request 97 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westerveltco/django-simple-nav/62565ad7827d52877592a6e1fe60e0501067546c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | import pytest 7 | from django.conf import settings 8 | from django.http import HttpRequest 9 | 10 | from .settings import DEFAULT_SETTINGS 11 | 12 | pytest_plugins = [] 13 | 14 | 15 | def pytest_configure(config): 16 | logging.disable(logging.CRITICAL) 17 | 18 | settings.configure(**DEFAULT_SETTINGS, **TEST_SETTINGS) 19 | 20 | 21 | TEST_SETTINGS = { 22 | "INSTALLED_APPS": [ 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django_simple_nav", 26 | "tests", 27 | ], 28 | "ROOT_URLCONF": "tests.urls", 29 | "TEMPLATES": [ 30 | { 31 | "BACKEND": "django.template.backends.django.DjangoTemplates", 32 | "APP_DIRS": True, 33 | "DIRS": [Path(__file__).parent / "templates"], 34 | } 35 | ], 36 | } 37 | 38 | 39 | @pytest.fixture 40 | def req(): 41 | # adding a HTTP_HOST header for now to fix tests, but 42 | # we really should switch to using a RequestFactory 43 | # instead of this fixture 44 | request = HttpRequest() 45 | request.META = {"HTTP_HOST": "test"} 46 | return request 47 | -------------------------------------------------------------------------------- /tests/navs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_simple_nav.nav import Nav 4 | from django_simple_nav.nav import NavGroup 5 | from django_simple_nav.nav import NavItem 6 | 7 | 8 | class DummyNav(Nav): 9 | template_name = "tests/dummy_nav.html" 10 | items = [ 11 | NavItem(title="Relative URL", url="/relative-url"), 12 | NavItem(title="Absolute URL", url="https://example.com/absolute-url"), 13 | NavItem(title="Named URL", url="fake-view"), 14 | NavGroup( 15 | title="Group", 16 | url="/group", 17 | items=[ 18 | NavItem(title="Relative URL", url="/relative-url"), 19 | NavItem(title="Absolute URL", url="https://example.com/absolute-url"), 20 | NavItem(title="Named URL", url="fake-view"), 21 | ], 22 | ), 23 | NavItem( 24 | title="is_authenticated Item", url="#", permissions=["is_authenticated"] 25 | ), 26 | NavItem(title="is_staff Item", url="#", permissions=["is_staff"]), 27 | NavItem(title="is_superuser Item", url="#", permissions=["is_superuser"]), 28 | NavItem( 29 | title="tests.dummy_perm Item", url="#", permissions=["tests.dummy_perm"] 30 | ), 31 | NavGroup( 32 | title="is_authenticated Group", 33 | permissions=["is_authenticated"], 34 | items=[NavItem(title="Test Item", url="#")], 35 | ), 36 | NavGroup( 37 | title="is_staff Group", 38 | permissions=["is_staff"], 39 | items=[NavItem(title="Test Item", url="#")], 40 | ), 41 | NavGroup( 42 | title="is_superuser Group", 43 | permissions=["is_superuser"], 44 | items=[NavItem(title="Test Item", url="#")], 45 | ), 46 | NavGroup( 47 | title="tests.dummy_perm Group", 48 | permissions=["tests.dummy_perm"], 49 | items=[NavItem(title="Test Item", url="#")], 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DEFAULT_SETTINGS = { 4 | "ALLOWED_HOSTS": ["*"], 5 | "DEBUG": False, 6 | "CACHES": { 7 | "default": { 8 | "BACKEND": "django.core.cache.backends.dummy.DummyCache", 9 | } 10 | }, 11 | "DATABASES": { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | } 16 | }, 17 | "EMAIL_BACKEND": "django.core.mail.backends.locmem.EmailBackend", 18 | "LOGGING_CONFIG": None, 19 | "PASSWORD_HASHERS": [ 20 | "django.contrib.auth.hashers.MD5PasswordHasher", 21 | ], 22 | "SECRET_KEY": "not-a-secret", 23 | } 24 | -------------------------------------------------------------------------------- /tests/templates/tests/alternate.html: -------------------------------------------------------------------------------- 1 |
This is an alternate template.
2 | {% include "tests/dummy_nav.html" %} 3 | -------------------------------------------------------------------------------- /tests/templates/tests/dummy_nav.html: -------------------------------------------------------------------------------- 1 |
    2 | {% for item in items %} 3 |
  • 4 | {{ item.title }} 5 | {% if item.items %} 6 | {% include "tests/dummy_nav.html" with items=item.items %} 7 | {% endif %} 8 |
  • 9 | {% endfor %} 10 |
11 | -------------------------------------------------------------------------------- /tests/templates/tests/jinja2/alternate.html: -------------------------------------------------------------------------------- 1 |
This is an alternate template.
2 | {% include "tests/dummy_nav.html" %} 3 | -------------------------------------------------------------------------------- /tests/templates/tests/jinja2/dummy_nav.html: -------------------------------------------------------------------------------- 1 |
    2 | {% for item in items %} 3 |
  • 4 | {{ item.title }} 5 | {% if item.items %} 6 | {% include "tests/dummy_nav.html" %} 7 | {% endif %} 8 |
  • 9 | {% endfor %} 10 |
11 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from django_simple_nav.conf import app_settings 6 | 7 | 8 | def test_app_settings(): 9 | # stub test until `django-simple-nav` requires custom app settings 10 | with pytest.raises(AttributeError): 11 | assert app_settings.foo 12 | -------------------------------------------------------------------------------- /tests/test_nav.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.template.backends.django import Template as DjangoTemplate 9 | from django.template.backends.jinja2 import Template as JinjaTemplate 10 | from django.test import override_settings 11 | from model_bakery import baker 12 | 13 | from django_simple_nav.nav import Nav 14 | from django_simple_nav.nav import NavItem 15 | from tests.navs import DummyNav 16 | from tests.utils import count_anchors 17 | 18 | pytestmark = pytest.mark.django_db 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "user, expected_count", 23 | [ 24 | (AnonymousUser(), 7), 25 | (None, 10), 26 | ], 27 | ) 28 | def test_nav_render(user, expected_count, req): 29 | if not isinstance(user, AnonymousUser): 30 | user = baker.make(get_user_model()) 31 | 32 | req.user = user 33 | 34 | rendered_nav = DummyNav().render(req) 35 | 36 | assert count_anchors(rendered_nav) == expected_count 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "permission, expected_count", 41 | [ 42 | ("", 10), # regular authenticated user 43 | ("is_staff", 13), 44 | ("is_superuser", 19), 45 | ("tests.dummy_perm", 13), 46 | ], 47 | ) 48 | def test_nav_render_permissions(req, permission, expected_count): 49 | user = baker.make(get_user_model()) 50 | 51 | if permission == "tests.dummy_perm": 52 | dummy_perm = baker.make( 53 | "auth.Permission", 54 | codename="dummy_perm", 55 | name="Dummy Permission", 56 | content_type=baker.make("contenttypes.ContentType", app_label="tests"), 57 | ) 58 | user.user_permissions.add(dummy_perm) 59 | else: 60 | setattr(user, permission, True) 61 | 62 | user.save() 63 | req.user = user 64 | 65 | rendered_nav = DummyNav().render(req) 66 | 67 | assert count_anchors(rendered_nav) == expected_count 68 | 69 | 70 | def test_nav_render_template_name(req): 71 | req.user = AnonymousUser() 72 | 73 | rendered_nav = DummyNav().render(req, "tests/alternate.html") 74 | 75 | assert "This is an alternate template." in rendered_nav 76 | 77 | 78 | def test_nav_render_template_string(req): 79 | class StringTemplateNav(Nav): 80 | title = ... 81 | items = [ 82 | NavItem(title=..., url="/test/"), 83 | ] 84 | 85 | def get_template(self, template_name): 86 | return "

This is a string.

" 87 | 88 | rendered_nav = StringTemplateNav().render(req) 89 | 90 | assert "

This is a string.

" in rendered_nav 91 | assert count_anchors(rendered_nav) == 0 92 | 93 | 94 | def test_get_context_data(req): 95 | context = DummyNav().get_context_data(req) 96 | 97 | assert context["items"] is not None 98 | 99 | 100 | def test_get_context_data_override(req): 101 | class OverrideNav(DummyNav): 102 | def get_context_data(self, request): 103 | return {"foo": "bar"} 104 | 105 | context = OverrideNav().get_context_data(req) 106 | 107 | assert context["foo"] == "bar" 108 | 109 | 110 | def test_get_items(req): 111 | class GetItemsNav(Nav): 112 | template_name = ... 113 | items = [ 114 | NavItem(title=..., url=...), 115 | ] 116 | 117 | items = GetItemsNav().get_items(req) 118 | 119 | assert len(items) == 1 120 | 121 | 122 | def test_get_items_override(req): 123 | class GetItemsNav(Nav): 124 | template_name = ... 125 | 126 | def get_items(self, request): 127 | return [ 128 | NavItem(title=..., url=...), 129 | ] 130 | 131 | items = GetItemsNav().get_items(req) 132 | 133 | assert len(items) == 1 134 | 135 | 136 | def test_get_items_improperly_configured(req): 137 | class GetItemsNav(Nav): 138 | template_name = ... 139 | 140 | with pytest.raises(ImproperlyConfigured): 141 | GetItemsNav().get_items(req) 142 | 143 | 144 | @pytest.mark.parametrize( 145 | "engine,expected", 146 | [ 147 | ( 148 | "django.template.backends.django.DjangoTemplates", 149 | DjangoTemplate, 150 | ), 151 | ( 152 | "django.template.backends.jinja2.Jinja2", 153 | JinjaTemplate, 154 | ), 155 | ], 156 | ) 157 | def test_get_template_engines(engine, expected): 158 | class TemplateEngineNav(Nav): 159 | template_name = ( 160 | "tests/dummy_nav.html" 161 | if engine.endswith("DjangoTemplates") 162 | else "tests/jinja2/dummy_nav.html" 163 | ) 164 | items = [...] 165 | 166 | with override_settings(TEMPLATES=[dict(settings.TEMPLATES[0], BACKEND=engine)]): 167 | template = TemplateEngineNav().get_template() 168 | 169 | assert isinstance(template, expected) 170 | 171 | 172 | def test_get_template_override(req): 173 | class TemplateOverrideNav(Nav): 174 | items = [NavItem(title=..., url="/test/")] 175 | 176 | def get_template(self, *args, **kwargs): 177 | return "

Overridden Template

" 178 | 179 | template = TemplateOverrideNav().get_template() 180 | 181 | assert isinstance(template, str) 182 | 183 | rendered_nav = TemplateOverrideNav().render(req) 184 | 185 | assert "

Overridden Template

" in rendered_nav 186 | 187 | 188 | def test_get_template_argument(): 189 | class TemplateOverrideNav(Nav): 190 | template_name = "foo.html" 191 | items = [...] 192 | 193 | template = TemplateOverrideNav().get_template(template_name="tests/dummy_nav.html") 194 | 195 | assert "tests/dummy_nav.html" in str(template.origin) 196 | assert "foo.html" not in str(template.origin) 197 | 198 | 199 | def test_get_template_name(): 200 | class GetTemplateNameNav(Nav): 201 | template_name = "tests/dummy_nav.html" 202 | items = [...] 203 | 204 | template_name = GetTemplateNameNav().get_template_name() 205 | 206 | assert template_name == "tests/dummy_nav.html" 207 | 208 | 209 | def test_get_template_name_override(): 210 | class GetTemplateNameNav(Nav): 211 | items = [...] 212 | 213 | def get_template_name(self): 214 | return "tests/dummy_nav.html" 215 | 216 | template_name = GetTemplateNameNav().get_template_name() 217 | 218 | assert template_name == "tests/dummy_nav.html" 219 | 220 | 221 | def test_get_template_name_improperly_configured(): 222 | class GetTemplateNameNav(Nav): 223 | items = [...] 224 | 225 | with pytest.raises(ImproperlyConfigured): 226 | GetTemplateNameNav().get_template_name() 227 | -------------------------------------------------------------------------------- /tests/test_navgroup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import AnonymousUser 6 | from django.test import override_settings 7 | from model_bakery import baker 8 | 9 | from django_simple_nav.nav import NavGroup 10 | from django_simple_nav.nav import NavItem 11 | 12 | pytestmark = pytest.mark.django_db 13 | 14 | 15 | def test_get_context_data(req): 16 | group = NavGroup( 17 | title="Test", 18 | items=[ 19 | NavItem( 20 | title="Test", 21 | url="/test/", 22 | extra_context={"foo": "bar"}, 23 | ), 24 | ], 25 | url="/test/", 26 | extra_context={"baz": "qux"}, 27 | ) 28 | 29 | group_context = group.get_context_data(req) 30 | 31 | assert {"title", "url", "active", "items", "baz"} == set(group_context.keys()) 32 | assert group_context.get("title") == "Test" 33 | assert group_context.get("url") == "/test/" 34 | assert group_context.get("active") is False 35 | assert group_context.get("items") is not None 36 | assert len(group_context.get("items")) == 1 37 | assert group_context.get("baz") == "qux" 38 | 39 | item_context = group_context.get("items")[0] 40 | 41 | assert {"title", "url", "active", "items", "foo"} == set(item_context.keys()) 42 | assert item_context.get("title") == "Test" 43 | assert item_context.get("url") == "/test/" 44 | assert item_context.get("active") is False 45 | assert item_context.get("items") is None 46 | assert item_context.get("foo") == "bar" 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "items,expected", 51 | [ 52 | ( 53 | [ 54 | NavItem(title=..., url="/test/"), 55 | NavItem(title=..., url="/test2/"), 56 | ], 57 | 2, 58 | ), 59 | ( 60 | [ 61 | NavItem(title=..., url="/test/", permissions=["is_authenticated"]), 62 | NavItem(title=..., url="/test2/"), 63 | ], 64 | 1, 65 | ), 66 | ( 67 | [ 68 | NavItem(title=..., url="/test/", permissions=["is_authenticated"]), 69 | NavItem(title=..., url="/test2/", permissions=["is_authenticated"]), 70 | ], 71 | 0, 72 | ), 73 | ], 74 | ) 75 | def test_get_context_data_items_anonymous(items, expected, req): 76 | group = NavGroup(title=..., items=items) 77 | 78 | req.user = AnonymousUser() 79 | 80 | group_context = group.get_context_data(req) 81 | 82 | assert len(group_context.get("items")) == expected 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "items,expected", 87 | [ 88 | ( 89 | [ 90 | NavItem(title=..., url="/test/"), 91 | NavItem(title=..., url="/test2/"), 92 | ], 93 | 2, 94 | ), 95 | ( 96 | [ 97 | NavItem(title=..., url="/test/", permissions=["is_authenticated"]), 98 | NavItem(title=..., url="/test2/"), 99 | ], 100 | 2, 101 | ), 102 | ( 103 | [ 104 | NavItem(title=..., url="/test/", permissions=["is_authenticated"]), 105 | NavItem(title=..., url="/test2/", permissions=["is_authenticated"]), 106 | ], 107 | 2, 108 | ), 109 | ], 110 | ) 111 | def test_get_context_data_items_is_authenticated(items, expected, req): 112 | group = NavGroup(title=..., items=items) 113 | 114 | req.user = baker.make(get_user_model()) 115 | 116 | group_context = group.get_context_data(req) 117 | 118 | assert len(group_context.get("items")) == expected 119 | 120 | 121 | def test_get_items(req): 122 | group = NavGroup( 123 | title=..., 124 | items=[ 125 | NavItem(title=..., url="/test/"), 126 | NavItem(title=..., url="/test2/"), 127 | ], 128 | ) 129 | 130 | items = group.get_items(req) 131 | 132 | assert items[0].url == "/test/" 133 | assert items[1].url == "/test2/" 134 | 135 | 136 | def test_get_items_override(req): 137 | class GetItemsNavGroup(NavGroup): 138 | def get_items(self, request): 139 | items = super().get_items(request) 140 | return [ 141 | NavItem(title=item.title, url=f"{item.url.rstrip('/')}/overridden/") 142 | for item in items 143 | ] 144 | 145 | group = GetItemsNavGroup( 146 | title=..., 147 | items=[ 148 | NavItem(title=..., url="/test/"), 149 | NavItem(title=..., url="/test2/"), 150 | ], 151 | ) 152 | 153 | items = group.get_items(req) 154 | 155 | assert items[0].url == "/test/overridden/" 156 | assert items[1].url == "/test2/overridden/" 157 | 158 | 159 | @pytest.mark.parametrize( 160 | "url,append_slash,expected", 161 | [ 162 | (None, True, ""), 163 | (None, False, ""), 164 | ("/test", True, "/test/"), 165 | ("/test", False, "/test"), 166 | ("home", True, "/"), 167 | ("home", False, "/"), 168 | ], 169 | ) 170 | def test_get_url(url, append_slash, expected): 171 | group = NavGroup(title=..., url=url, items=[...]) 172 | 173 | with override_settings(APPEND_SLASH=append_slash): 174 | rendered_url = group.get_url() 175 | 176 | if rendered_url not in ["", "/"]: 177 | assert rendered_url.endswith("/") is append_slash 178 | assert rendered_url == expected 179 | 180 | 181 | def test_get_url_override(): 182 | class GetURLNavGroup(NavGroup): 183 | def get_url(self): 184 | url = super().get_url() 185 | if url == "": 186 | raise ValueError 187 | return url 188 | 189 | group = GetURLNavGroup(title=..., items=[...]) 190 | 191 | with pytest.raises(ValueError): 192 | group.get_url() 193 | 194 | 195 | @pytest.mark.parametrize( 196 | "url,expected", 197 | [ 198 | ("/test/", True), 199 | ("/test/active/", True), 200 | ("/foo/", False), 201 | ], 202 | ) 203 | def test_get_active(url, expected, rf): 204 | group = NavGroup( 205 | title=..., 206 | url="http://testserver/test/", 207 | items=[ 208 | NavItem(title=..., url="http://testserver/test/active/"), 209 | NavItem(title=..., url="http://testserver/test/not-active/"), 210 | ], 211 | ) 212 | 213 | req = rf.get(url) 214 | 215 | assert group.get_active(req) is expected 216 | 217 | 218 | @pytest.mark.parametrize( 219 | "url,items,expected", 220 | [ 221 | ( 222 | None, 223 | [ 224 | NavItem(title=..., url=...), 225 | NavItem(title=..., url=...), 226 | ], 227 | True, 228 | ), 229 | ( 230 | "/test/", 231 | [ 232 | NavItem(title=..., url=...), 233 | NavItem(title=..., url=...), 234 | ], 235 | True, 236 | ), 237 | ( 238 | None, 239 | [ 240 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 241 | NavItem(title=..., url=...), 242 | ], 243 | True, 244 | ), 245 | ( 246 | "/test/", 247 | [ 248 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 249 | NavItem(title=..., url=...), 250 | ], 251 | True, 252 | ), 253 | ( 254 | None, 255 | [ 256 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 257 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 258 | ], 259 | False, 260 | ), 261 | ( 262 | "/test/", 263 | [ 264 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 265 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 266 | ], 267 | True, 268 | ), 269 | ], 270 | ) 271 | def test_items_check_permissions_anonymous(url, items, expected, req): 272 | group = NavGroup(title=..., url=url, items=items) 273 | 274 | req.user = AnonymousUser() 275 | 276 | assert group.check_permissions(req) is expected 277 | 278 | 279 | @pytest.mark.parametrize( 280 | "url,items,expected", 281 | [ 282 | ( 283 | None, 284 | [ 285 | NavItem(title=..., url=...), 286 | NavItem(title=..., url=...), 287 | ], 288 | True, 289 | ), 290 | ( 291 | "/test/", 292 | [ 293 | NavItem(title=..., url=...), 294 | NavItem(title=..., url=...), 295 | ], 296 | True, 297 | ), 298 | ( 299 | None, 300 | [ 301 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 302 | NavItem(title=..., url=...), 303 | ], 304 | True, 305 | ), 306 | ( 307 | "/test/", 308 | [ 309 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 310 | NavItem(title=..., url=...), 311 | ], 312 | True, 313 | ), 314 | ( 315 | None, 316 | [ 317 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 318 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 319 | ], 320 | True, 321 | ), 322 | ( 323 | "/test/", 324 | [ 325 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 326 | NavItem(title=..., url=..., permissions=["is_authenticated"]), 327 | ], 328 | True, 329 | ), 330 | ], 331 | ) 332 | def test_items_check_permissions_is_authenticated(url, items, expected, req): 333 | group = NavGroup(title=..., url=url, items=items) 334 | 335 | req.user = baker.make(get_user_model()) 336 | 337 | assert group.check_permissions(req) is expected 338 | -------------------------------------------------------------------------------- /tests/test_navitem.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.test import override_settings 9 | from django.urls import reverse 10 | from django.urls import reverse_lazy 11 | from model_bakery import baker 12 | 13 | from django_simple_nav.nav import NavItem 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | 18 | def test_get_context_data(req): 19 | item = NavItem( 20 | title="Test", 21 | url="/test/", 22 | permissions=["is_authenticated"], 23 | extra_context={"foo": "bar"}, 24 | ) 25 | 26 | context = item.get_context_data(req) 27 | 28 | assert {"title", "url", "active", "items", "foo"} == set(context.keys()) 29 | assert context.get("title") == "Test" 30 | assert context.get("url") == "/test/" 31 | assert context.get("active") is False 32 | assert context.get("items") is None 33 | assert context.get("foo") == "bar" 34 | 35 | 36 | def test_get_context_data_no_extra_context(req): 37 | item = NavItem( 38 | title="Test", 39 | url="/test/", 40 | ) 41 | 42 | rendered_item = item.get_context_data(req) 43 | 44 | assert rendered_item.get("foo") is None 45 | 46 | 47 | def test_get_context_data_extra_context_shadowing(req): 48 | item = NavItem( 49 | title="Test", 50 | url="/test/", 51 | extra_context={"title": "Shadowed"}, 52 | ) 53 | 54 | rendered_item = item.get_context_data(req) 55 | 56 | assert rendered_item.get("title") == "Test" 57 | 58 | 59 | def test_get_title(): 60 | item = NavItem(title="Test") 61 | 62 | assert item.get_title() == "Test" 63 | 64 | 65 | def test_get_title_safestring(): 66 | item = NavItem(title="Test!!!") 67 | 68 | assert item.get_title() == "Test!!!" 69 | 70 | 71 | def test_get_title_override(): 72 | class GetTitleNavItem(NavItem): 73 | def get_title(self): 74 | return f"{self.title}!!!" 75 | 76 | item = GetTitleNavItem(title="Test") 77 | 78 | assert item.get_title() == "Test!!!" 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "url,append_slash,expected", 83 | [ 84 | ("/test", True, "/test/"), 85 | ("/test", False, "/test"), 86 | ("home", True, "/"), 87 | ("home", False, "/"), 88 | (reverse("home"), True, "/"), 89 | (reverse("home"), False, "/"), 90 | (reverse_lazy("home"), True, "/"), 91 | (reverse_lazy("home"), False, "/"), 92 | ], 93 | ) 94 | def test_get_url(url, append_slash, expected): 95 | item = NavItem(title=..., url=url) 96 | 97 | with override_settings(APPEND_SLASH=append_slash): 98 | rendered_url = item.get_url() 99 | 100 | if rendered_url != "/": 101 | assert rendered_url.endswith("/") is append_slash 102 | assert rendered_url == expected 103 | 104 | 105 | def test_get_url_improperly_configured(): 106 | item = NavItem(title=..., url=None) 107 | 108 | with pytest.raises(ImproperlyConfigured): 109 | item.get_url() 110 | 111 | 112 | def test_get_url_override(): 113 | class GetURLNavItem(NavItem): 114 | def get_url(self): 115 | return "/" 116 | 117 | item = GetURLNavItem(title=..., url=None) 118 | 119 | assert item.get_url() == "/" 120 | 121 | 122 | @pytest.mark.parametrize( 123 | "url,req_path,req_params,expected", 124 | [ 125 | ("/test/", "/test/", None, True), 126 | ("/test/", "/other/", None, False), 127 | ("fake-view", "/fake-view/", None, True), 128 | ("/test", "/test/", None, True), 129 | ("/test/", "/test", None, True), 130 | ("/test/nested/", "/test/", None, False), 131 | ("/test/?query=param", "/test/", {"query": "param"}, True), 132 | ("/test/?query=param", "/test/", None, False), 133 | ], 134 | ) 135 | def test_active(url, req_path, req_params, expected, rf): 136 | item = NavItem(title=..., url=f"http://testserver/{url.lstrip('/')}") 137 | 138 | req = rf.get(req_path, req_params) 139 | 140 | assert item.get_active(req) == expected 141 | 142 | 143 | def test_active_different_scheme(rf): 144 | item = NavItem(title=..., url="https://testserver/") 145 | 146 | req = rf.get("/") 147 | 148 | assert item.get_active(req) is False 149 | 150 | 151 | def test_active_different_domain(rf): 152 | item = NavItem(title=..., url="http://different-domain/") 153 | 154 | req = rf.get("/") 155 | 156 | assert item.get_active(req) is False 157 | 158 | 159 | def test_active_no_scheme_no_netloc(rf): 160 | item = NavItem(title=..., url="/test/") 161 | 162 | req = rf.get("/test") 163 | 164 | assert item.get_active(req) is True 165 | 166 | 167 | @pytest.mark.parametrize("append_slash", [True, False]) 168 | def test_active_append_slash_setting(append_slash, rf): 169 | item = NavItem(title=..., url="http://testserver/test") 170 | 171 | req = rf.get("/test") 172 | 173 | with override_settings(APPEND_SLASH=append_slash): 174 | assert item.get_active(req) is True 175 | 176 | 177 | def test_active_improperly_configured(req): 178 | item = NavItem(title=..., url=None) 179 | 180 | req.path = "/" 181 | 182 | assert item.get_active(req) is False 183 | 184 | 185 | def test_active_reverse_no_match(req): 186 | item = NavItem(title=..., url="nonexistent") 187 | 188 | req.path = "/" 189 | 190 | assert item.get_active(req) is False 191 | 192 | 193 | def test_get_items(req): 194 | item = NavItem(title=..., url=...) 195 | 196 | assert item.get_items(req) is None 197 | 198 | 199 | @pytest.mark.parametrize( 200 | "permissions,expected", 201 | [ 202 | ([], True), 203 | (["is_authenticated"], False), 204 | (["is_staff"], False), 205 | (["is_superuser"], False), 206 | (["is_authenticated", "is_staff"], False), 207 | (["is_authenticated", "is_superuser"], False), 208 | ], 209 | ) 210 | def test_check_permissions_anonymous(permissions, expected, req): 211 | item = NavItem(title=..., url=..., permissions=permissions) 212 | 213 | req.user = AnonymousUser() 214 | 215 | assert item.check_permissions(req) is expected 216 | 217 | 218 | @pytest.mark.parametrize( 219 | "permissions,expected", 220 | [ 221 | ([], True), 222 | (["is_authenticated"], True), 223 | (["is_staff"], False), 224 | (["is_superuser"], False), 225 | (["is_authenticated", "is_staff"], False), 226 | (["is_authenticated", "is_superuser"], False), 227 | ], 228 | ) 229 | def test_check_permissions_is_authenticated(permissions, expected, req): 230 | item = NavItem(title=..., url=..., permissions=permissions) 231 | 232 | req.user = baker.make(get_user_model()) 233 | 234 | assert item.check_permissions(req) is expected 235 | 236 | 237 | @pytest.mark.parametrize( 238 | "permissions,expected", 239 | [ 240 | ([], True), 241 | (["is_authenticated"], True), 242 | (["is_staff"], True), 243 | (["is_superuser"], False), 244 | (["is_authenticated", "is_staff"], True), 245 | (["is_authenticated", "is_superuser"], False), 246 | ], 247 | ) 248 | def test_check_permissions_is_staff(permissions, expected, req): 249 | item = NavItem(title=..., url=..., permissions=permissions) 250 | 251 | req.user = baker.make(get_user_model(), is_staff=True) 252 | 253 | assert item.check_permissions(req) is expected 254 | 255 | 256 | @pytest.mark.parametrize( 257 | "permissions,expected", 258 | [ 259 | ([], True), 260 | (["is_authenticated"], True), 261 | (["is_staff"], True), 262 | (["is_superuser"], True), 263 | (["is_authenticated", "is_staff"], True), 264 | (["is_authenticated", "is_superuser"], True), 265 | ], 266 | ) 267 | def test_check_permissions_is_superuser(permissions, expected, req): 268 | item = NavItem(title=..., url=..., permissions=permissions) 269 | 270 | req.user = baker.make(get_user_model(), is_superuser=True) 271 | 272 | assert item.check_permissions(req) is expected 273 | 274 | 275 | @pytest.mark.parametrize( 276 | "permissions,expected", 277 | [ 278 | ([], True), 279 | (["is_authenticated"], False), 280 | (["is_staff"], False), 281 | (["is_superuser"], False), 282 | (["is_authenticated", "is_staff"], False), 283 | (["is_authenticated", "is_superuser"], False), 284 | ], 285 | ) 286 | def test_check_permissions_no_request_user(permissions, expected, req): 287 | item = NavItem(title=..., url=..., permissions=permissions) 288 | 289 | assert item.check_permissions(req) is expected 290 | 291 | 292 | @pytest.mark.parametrize( 293 | "permissions,expected", 294 | [ 295 | ([], True), 296 | (["is_authenticated"], True), 297 | (["is_staff"], True), 298 | (["is_superuser"], True), 299 | (["is_authenticated", "is_staff"], True), 300 | (["is_authenticated", "is_superuser"], True), 301 | ], 302 | ) 303 | @override_settings( 304 | INSTALLED_APPS=[ 305 | app for app in settings.INSTALLED_APPS if app != "django.contrib.auth" 306 | ] 307 | ) 308 | def test_check_permissions_no_contrib_auth(permissions, expected, req, caplog): 309 | item = NavItem(title=..., url=..., permissions=permissions) 310 | 311 | with caplog.at_level("WARNING"): 312 | assert item.check_permissions(req) is expected 313 | 314 | assert "The 'django.contrib.auth' app is not installed" in caplog.text 315 | 316 | 317 | def test_check_permissions_callable_anonymous(req): 318 | def dummy_check(request): 319 | return True 320 | 321 | item = NavItem(title=..., url=..., permissions=[dummy_check]) 322 | 323 | req.user = AnonymousUser() 324 | 325 | assert item.check_permissions(req) 326 | 327 | 328 | @pytest.mark.parametrize("is_authenticated", [True, False]) 329 | def test_check_permissions_callable_is_authenticated(is_authenticated, req): 330 | def check_is_authenticated(request): 331 | return request.user.is_authenticated 332 | 333 | item = NavItem(title=..., url=..., permissions=[check_is_authenticated]) 334 | 335 | req.user = baker.make(get_user_model()) if is_authenticated else AnonymousUser() 336 | 337 | assert item.check_permissions(req) is is_authenticated 338 | 339 | 340 | @pytest.mark.parametrize( 341 | "permissions,expected", 342 | [ 343 | ([], True), 344 | (["is_authenticated"], True), 345 | (["is_staff"], False), 346 | (["is_superuser"], False), 347 | (["is_authenticated", "is_staff"], False), 348 | (["is_authenticated", "is_superuser"], False), 349 | ], 350 | ) 351 | def test_check_permissions_auth_permission_is_authenticated(permissions, expected, req): 352 | dummy_perm = baker.make( 353 | "auth.Permission", 354 | codename="dummy_perm", 355 | name="Dummy Permission", 356 | content_type=baker.make("contenttypes.ContentType", app_label="tests"), 357 | ) 358 | 359 | item = NavItem( 360 | title=..., 361 | url=..., 362 | permissions=permissions 363 | + [f"{dummy_perm.content_type.app_label}.{dummy_perm.codename}"], 364 | ) 365 | 366 | user = baker.make(get_user_model()) 367 | user.user_permissions.add(dummy_perm) 368 | req.user = user 369 | 370 | assert item.check_permissions(req) == expected 371 | 372 | 373 | @pytest.mark.parametrize( 374 | "permissions,expected", 375 | [ 376 | ([], True), 377 | (["is_authenticated"], True), 378 | (["is_staff"], True), 379 | (["is_superuser"], False), 380 | (["is_authenticated", "is_staff"], True), 381 | (["is_authenticated", "is_superuser"], False), 382 | ], 383 | ) 384 | def test_check_permissions_auth_permission_is_staff(permissions, expected, req): 385 | dummy_perm = baker.make( 386 | "auth.Permission", 387 | codename="dummy_perm", 388 | name="Dummy Permission", 389 | content_type=baker.make("contenttypes.ContentType", app_label="tests"), 390 | ) 391 | 392 | item = NavItem( 393 | title=..., 394 | url=..., 395 | permissions=permissions 396 | + [f"{dummy_perm.content_type.app_label}.{dummy_perm.codename}"], 397 | ) 398 | 399 | user = baker.make(get_user_model(), is_staff=True) 400 | user.user_permissions.add(dummy_perm) 401 | req.user = user 402 | 403 | assert item.check_permissions(req) == expected 404 | 405 | 406 | @pytest.mark.parametrize( 407 | "permissions,expected", 408 | [ 409 | ([], True), 410 | (["is_authenticated"], True), 411 | (["is_staff"], True), 412 | (["is_superuser"], True), 413 | (["is_authenticated", "is_staff"], True), 414 | (["is_authenticated", "is_superuser"], True), 415 | ], 416 | ) 417 | def test_check_permissions_auth_permission_is_superuser(permissions, expected, req): 418 | dummy_perm = baker.make( 419 | "auth.Permission", 420 | codename="dummy_perm", 421 | name="Dummy Permission", 422 | content_type=baker.make("contenttypes.ContentType", app_label="tests"), 423 | ) 424 | 425 | item = NavItem( 426 | title=..., 427 | url=..., 428 | permissions=permissions 429 | + [f"{dummy_perm.content_type.app_label}.{dummy_perm.codename}"], 430 | ) 431 | 432 | user = baker.make(get_user_model(), is_superuser=True) 433 | user.user_permissions.add(dummy_perm) 434 | req.user = user 435 | 436 | assert item.check_permissions(req) == expected 437 | -------------------------------------------------------------------------------- /tests/test_templates.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.test import override_settings 7 | 8 | from django_simple_nav._templates import get_template_engine 9 | 10 | 11 | def test_get_template_engine(): 12 | engine = get_template_engine() 13 | 14 | assert engine.name in settings.TEMPLATES[0].get("BACKEND") 15 | 16 | 17 | @override_settings(TEMPLATES=[]) 18 | def test_get_template_engine_no_engine(): 19 | with pytest.raises(ImproperlyConfigured): 20 | get_template_engine() 21 | 22 | 23 | @override_settings( 24 | TEMPLATES=[ 25 | { 26 | "BACKEND": "django.template.backends.django.DjangoTemplates", 27 | }, 28 | { 29 | "BACKEND": "django.template.backends.jinja2.Jinja2", 30 | }, 31 | ] 32 | ) 33 | @pytest.mark.parametrize( 34 | "templates,expected", 35 | [ 36 | ( 37 | [ 38 | { 39 | "BACKEND": "django.template.backends.django.DjangoTemplates", 40 | }, 41 | { 42 | "BACKEND": "django.template.backends.jinja2.Jinja2", 43 | }, 44 | ], 45 | "django", 46 | ), 47 | ( 48 | [ 49 | { 50 | "BACKEND": "django.template.backends.jinja2.Jinja2", 51 | }, 52 | { 53 | "BACKEND": "django.template.backends.django.DjangoTemplates", 54 | }, 55 | ], 56 | "jinja2", 57 | ), 58 | ], 59 | ) 60 | def test_get_template_engine_multiple(templates, expected, caplog): 61 | with override_settings(TEMPLATES=templates): 62 | with caplog.at_level("WARNING"): 63 | engine = get_template_engine() 64 | 65 | assert engine.name == expected 66 | assert "Multiple `BACKEND` defined for a template engine." in caplog.text 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "using,expected", 71 | [ 72 | ("django", "django"), 73 | ("django2", "django2"), 74 | ("jinja2", "jinja2"), 75 | ], 76 | ) 77 | @override_settings( 78 | TEMPLATES=[ 79 | { 80 | "BACKEND": "django.template.backends.django.DjangoTemplates", 81 | }, 82 | { 83 | "BACKEND": "django.template.backends.django.DjangoTemplates", 84 | "NAME": "django2", 85 | }, 86 | { 87 | "BACKEND": "django.template.backends.jinja2.Jinja2", 88 | }, 89 | ] 90 | ) 91 | def test_get_template_engine_using(using, expected): 92 | engine = get_template_engine(using) 93 | 94 | assert engine.name == expected 95 | 96 | 97 | @pytest.mark.parametrize( 98 | "app_setting,expected", 99 | [ 100 | ("django.template.backends.django.DjangoTemplates", "django"), 101 | ("django.template.backends.jinja2.Jinja2", "jinja2"), 102 | ], 103 | ) 104 | def test_get_template_engine_app_setting(app_setting, expected): 105 | with override_settings( 106 | DJANGO_SIMPLE_NAV={"TEMPLATE_BACKEND": app_setting}, 107 | TEMPLATES=[ 108 | { 109 | "BACKEND": app_setting, 110 | }, 111 | ], 112 | ): 113 | engine = get_template_engine() 114 | 115 | assert engine.name == expected 116 | 117 | 118 | @override_settings( 119 | DJANGO_SIMPLE_NAV={"TEMPLATE_BACKEND": "invalid"}, 120 | ) 121 | def test_get_template_engine_app_setting_invalid(): 122 | with pytest.raises(ImproperlyConfigured) as exc_info: 123 | get_template_engine() 124 | 125 | assert "Invalid `TEMPLATE_BACKEND` for a template engine" in exc_info 126 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import AnonymousUser 6 | from django.template import Context 7 | from django.template import Template 8 | from django.template import TemplateSyntaxError 9 | from model_bakery import baker 10 | 11 | from django_simple_nav.nav import NavItem 12 | from tests.navs import DummyNav 13 | from tests.utils import count_anchors 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | 18 | def test_django_simple_nav_templatetag(req): 19 | template = Template( 20 | "{% load django_simple_nav %} {% django_simple_nav 'tests.navs.DummyNav' %}" 21 | ) 22 | req.user = AnonymousUser() 23 | 24 | rendered_template = template.render(Context({"request": req})) 25 | 26 | assert count_anchors(rendered_template) == 7 27 | 28 | 29 | def test_templatetag_with_template_name(req): 30 | template = Template( 31 | "{% load django_simple_nav %} {% django_simple_nav 'tests.navs.DummyNav' 'tests/alternate.html' %}" 32 | ) 33 | req.user = AnonymousUser() 34 | 35 | rendered_template = template.render(Context({"request": req})) 36 | 37 | assert "This is an alternate template." in rendered_template 38 | 39 | 40 | def test_templatetag_with_nav_instance(req): 41 | class PlainviewNav(DummyNav): 42 | items = [ 43 | NavItem(title="I drink your milkshake!", url="/milkshake/"), 44 | ] 45 | 46 | template = Template("{% load django_simple_nav %} {% django_simple_nav new_nav %}") 47 | req.user = baker.make(get_user_model(), first_name="Daniel", last_name="Plainview") 48 | 49 | rendered_template = template.render( 50 | Context({"request": req, "new_nav": PlainviewNav()}) 51 | ) 52 | 53 | assert "I drink your milkshake!" in rendered_template 54 | 55 | 56 | def test_templatetag_with_nav_instance_and_template_name(req): 57 | class DeadParrotNav(DummyNav): 58 | items = [ 59 | NavItem(title="He's pinin' for the fjords!", url="/notlob/"), 60 | ] 61 | 62 | template = Template( 63 | "{% load django_simple_nav %} {% django_simple_nav new_nav 'tests/alternate.html' %}" 64 | ) 65 | req.user = baker.make(get_user_model(), first_name="Norwegian", last_name="Blue") 66 | 67 | rendered_template = template.render( 68 | Context({"request": req, "new_nav": DeadParrotNav()}) 69 | ) 70 | 71 | assert "He's pinin' for the fjords!" in rendered_template 72 | assert "This is an alternate template." in rendered_template 73 | 74 | 75 | def test_templatetag_with_template_name_on_nav_instance(req): 76 | class PinkmanNav(DummyNav): 77 | template_name = "tests/alternate.html" 78 | items = [ 79 | NavItem(title="Yeah Mr. White! Yeah science!", url="/science/"), 80 | ] 81 | 82 | template = Template("{% load django_simple_nav %} {% django_simple_nav new_nav %}") 83 | req.user = baker.make(get_user_model(), first_name="Jesse", last_name="Pinkman") 84 | 85 | rendered_template = template.render( 86 | Context({"request": req, "new_nav": PinkmanNav()}) 87 | ) 88 | 89 | assert "Yeah Mr. White! Yeah science!" in rendered_template 90 | assert "This is an alternate template." in rendered_template 91 | 92 | 93 | def test_templatetag_with_no_arguments(): 94 | with pytest.raises(TemplateSyntaxError): 95 | Template("{% load django_simple_nav %} {% django_simple_nav %}") 96 | 97 | 98 | def test_templatetag_with_missing_variable(): 99 | template = Template( 100 | "{% load django_simple_nav %} {% django_simple_nav missing_nav %}" 101 | ) 102 | 103 | with pytest.raises(TemplateSyntaxError): 104 | template.render(Context({})) 105 | 106 | 107 | def test_nested_templatetag(req): 108 | # called twice to simulate a nested call 109 | template = Template( 110 | "{% load django_simple_nav %} {% django_simple_nav 'tests.navs.DummyNav' %}" 111 | "{% django_simple_nav 'tests.navs.DummyNav' %}" 112 | ) 113 | req.user = AnonymousUser() 114 | 115 | rendered_template = template.render(Context({"request": req})) 116 | 117 | assert count_anchors(rendered_template) == 14 118 | 119 | 120 | def test_invalid_dotted_string(req): 121 | template = Template( 122 | "{% load django_simple_nav %} {% django_simple_nav 'path.to.DoesNotExist' %}" 123 | ) 124 | 125 | with pytest.raises(TemplateSyntaxError): 126 | template.render(Context({"request": req})) 127 | 128 | 129 | class InvalidNav: ... 130 | 131 | 132 | def test_invalid_nav_instance(req): 133 | template = Template( 134 | "{% load django_simple_nav %} {% django_simple_nav 'tests.test_templatetags.InvalidNav' %}" 135 | ) 136 | 137 | with pytest.raises(TemplateSyntaxError): 138 | template.render(Context({"request": req})) 139 | 140 | 141 | def test_template_name_variable_does_not_exist(req): 142 | template = Template( 143 | "{% load django_simple_nav %} {% django_simple_nav 'tests.navs.DummyNav' nonexistent_template_name_variable %}" 144 | ) 145 | 146 | with pytest.raises(TemplateSyntaxError): 147 | template.render(Context({"request": req})) 148 | 149 | 150 | def test_request_not_in_context(): 151 | template = Template( 152 | "{% load django_simple_nav %} {% django_simple_nav 'tests.navs.DummyNav' %}" 153 | ) 154 | 155 | with pytest.raises(TemplateSyntaxError): 156 | template.render(Context()) 157 | 158 | 159 | def test_invalid_request(): 160 | class InvalidRequest: ... 161 | 162 | template = Template( 163 | "{% load django_simple_nav %} {% django_simple_nav 'tests.navs.DummyNav' %}" 164 | ) 165 | 166 | with pytest.raises(TemplateSyntaxError): 167 | template.render(Context({"request": InvalidRequest()})) 168 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_simple_nav import __version__ 4 | 5 | 6 | def test_version(): 7 | assert __version__ == "0.12.0" 8 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.urls import path 4 | 5 | 6 | def home(request): ... 7 | 8 | 9 | def fake_view(request): ... 10 | 11 | 12 | urlpatterns = [ 13 | path("fake-view/", fake_view, name="fake-view"), 14 | path("", home, name="home"), 15 | ] 16 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from html.parser import HTMLParser 4 | 5 | 6 | class AnchorParser(HTMLParser): 7 | def __init__(self): 8 | super().__init__() 9 | self.anchors = [] 10 | 11 | def handle_starttag(self, tag, attrs): 12 | if tag == "a": 13 | self.anchors.append(attrs[0][1]) 14 | 15 | 16 | def count_anchors(html: str) -> int: 17 | parser = AnchorParser() 18 | parser.feed(html) 19 | return len(parser.anchors) 20 | --------------------------------------------------------------------------------