├── .github └── workflows │ ├── python-publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── codecov.yml ├── dj_redis_panel ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── redis_utils.py ├── templates │ └── admin │ │ └── dj_redis_panel │ │ ├── base.html │ │ ├── index.html │ │ ├── instance_overview.html │ │ ├── key_add.html │ │ ├── key_detail.html │ │ ├── key_detail_add.html │ │ ├── key_detail_pagination.html │ │ ├── key_detail_pagination_info.html │ │ ├── key_detail_value_hash.html │ │ ├── key_detail_value_list.html │ │ ├── key_detail_value_set.html │ │ ├── key_detail_value_string.html │ │ ├── key_detail_value_zset.html │ │ ├── key_search.html │ │ └── styles.css ├── urls.py └── views.py ├── docker-compose.yml ├── docs ├── configuration.md ├── development.md ├── features.md ├── index.md ├── installation.md ├── quick-start.md └── testing.md ├── example_project ├── example_project │ ├── __init__.py │ ├── asgi.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── populate_redis.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── manage.py ├── images ├── admin_home.png ├── instance_overview.png ├── instances_list.png ├── key_detail_hash.png ├── key_detail_string.png ├── key_search_cursor.png └── key_search_page_index.png ├── mkdocs.yml ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── base.py ├── conftest.py ├── test_admin.py ├── test_collection_member_add.py ├── test_collection_member_delete.py ├── test_collection_member_edit.py ├── test_index.py ├── test_instance_overview.py ├── test_key_add.py ├── test_key_detail.py └── test_key_search.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.x" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python -m pip install build 33 | make build 34 | 35 | - name: Upload distributions 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: release-dists 39 | path: dist/ 40 | 41 | pypi-publish: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - release-build 45 | permissions: 46 | # IMPORTANT: this permission is mandatory for trusted publishing 47 | id-token: write 48 | 49 | # Dedicated environments with protections for publishing are strongly recommended. 50 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 51 | environment: 52 | name: pypi 53 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 54 | # url: https://pypi.org/p/YOURPROJECT 55 | # 56 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 57 | # ALTERNATIVE: exactly, uncomment the following line instead: 58 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 59 | 60 | steps: 61 | - name: Retrieve release distributions 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: release-dists 65 | path: dist/ 66 | 67 | - name: Publish release distributions to PyPI 68 | uses: pypa/gh-action-pypi-publish@release/v1 69 | with: 70 | packages-dir: dist/ 71 | 72 | docs-publish: 73 | runs-on: ubuntu-latest 74 | needs: 75 | - release-build 76 | - pypi-publish 77 | steps: 78 | - name: Publish documentation to GitHub Pages using MkDocs 79 | run: | 80 | make docs_push 81 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | python-tests: 7 | runs-on: ubuntu-latest 8 | name: Python ${{ matrix.python-version }} - Django ${{ matrix.django-version }} 9 | strategy: 10 | matrix: 11 | python-version: [3.9, 3.13] 12 | django-version: [4.2, 5.2] 13 | exclude: 14 | # Django 5.0+ requires Python 3.10+ 15 | - python-version: 3.9 16 | django-version: 5.2 17 | 18 | services: 19 | redis: 20 | image: redis:7.4.1 21 | ports: ["6379:6379"] 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Install package and dependencies 33 | run: | 34 | make install 35 | pip install "Django~=${{ matrix.django-version }}.0" 36 | 37 | - name: Run tests with coverage 38 | run: | 39 | pytest --cov=dj_redis_panel --cov-report=xml 40 | 41 | - name: Upload coverage to Codecov 42 | uses: codecov/codecov-action@v5 43 | with: 44 | token: ${{ secrets.CODECOV_TOKEN }} 45 | flags: unittests,python-${{ matrix.python-version }},django-${{ matrix.django-version }} 46 | fail_ci_if_error: true 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 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 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | .python-version 142 | env/ 143 | venv/ 144 | ENV/ 145 | env.bak/ 146 | venv.bak/ 147 | 148 | # Spyder project settings 149 | .spyderproject 150 | .spyproject 151 | 152 | # Rope project settings 153 | .ropeproject 154 | 155 | # mkdocs documentation 156 | /site 157 | 158 | # mypy 159 | .mypy_cache/ 160 | .dmypy.json 161 | dmypy.json 162 | 163 | # Pyre type checker 164 | .pyre/ 165 | 166 | # pytype static type analyzer 167 | .pytype/ 168 | 169 | # Cython debug symbols 170 | cython_debug/ 171 | 172 | # PyCharm 173 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 174 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 175 | # and can be added to the global gitignore or merged into this file. For a more nuclear 176 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 177 | #.idea/ 178 | 179 | # Abstra 180 | # Abstra is an AI-powered process automation framework. 181 | # Ignore directories containing user credentials, local state, and settings. 182 | # Learn more at https://abstra.io/docs 183 | .abstra/ 184 | 185 | # Visual Studio Code 186 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 187 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 188 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 189 | # you could uncomment the following to ignore the entire vscode folder 190 | # .vscode/ 191 | 192 | # Ruff stuff: 193 | .ruff_cache/ 194 | 195 | # PyPI configuration file 196 | .pypirc 197 | 198 | # Cursor 199 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 200 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 201 | # refer to https://docs.cursor.com/context/ignore-files 202 | .cursorignore 203 | .cursorindexingignore 204 | 205 | # Marimo 206 | marimo/_static/ 207 | marimo/_lsp/ 208 | __marimo__/ 209 | 210 | .specstory/ 211 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yasser Toruno 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include dj_redis_panel/templates * 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME = dj_redis_panel 2 | PYPI_REPO ?= pypi # can be 'testpypi' or 'pypi' 3 | 4 | .PHONY: help clean build publish test install 5 | 6 | help: 7 | @echo "Makefile targets:" 8 | @echo " make clean Remove build artifacts" 9 | @echo " make build Build sdist and wheel (in ./dist)" 10 | @echo " make install_requirements Install all dev dependencies" 11 | @echo " make install Install dependencies and package in editable mode" 12 | @echo " make uninstall Uninstall package" 13 | @echo " make uninstall_all Uninstall all packages" 14 | @echo " make test_install Check if package can be imported" 15 | @echo " make test Run tests" 16 | @echo " make test_coverage Run tests with coverage report" 17 | @echo " make coverage_html Generate HTML coverage report" 18 | @echo " make publish Publish package to PyPI" 19 | @echo " make docs Build documentation" 20 | @echo " make docs_serve Serve documentation locally" 21 | @echo " make docs_push Deploy documentation to GitHub Pages" 22 | 23 | clean: 24 | rm -rf build dist *.egg-info 25 | 26 | build: clean 27 | python -m build 28 | 29 | install_requirements: 30 | python -m pip install -r requirements.txt 31 | 32 | install: install_requirements 33 | python -m pip install -e . 34 | 35 | uninstall: 36 | python -m pip uninstall -y $(PACKAGE_NAME) || true 37 | 38 | uninstall_all: 39 | python -m pip uninstall -y $(PACKAGE_NAME) || true 40 | python -m pip uninstall -y -r requirements.txt || true 41 | @echo "All packages in requirements.txt uninstalled" 42 | @echo "Note that some dependent packages may still be installed" 43 | @echo "To uninstall all packages, run 'pip freeze | xargs pip uninstall -y'" 44 | @echo "Do this at your own risk. Use a python virtual environment always." 45 | 46 | test_install: build 47 | python -m pip uninstall -y $(PACKAGE_NAME) || true 48 | python -m pip install -e . 49 | python -c "import dj_redis_panel; print('✅ Import success!')" 50 | 51 | test: install 52 | python -m pytest tests/ 53 | 54 | test_coverage: install 55 | pytest --cov=dj_redis_panel --cov-report=xml --cov-report=html --cov-report=term-missing 56 | 57 | coverage_html: test_coverage 58 | @echo "Coverage report generated in htmlcov/index.html" 59 | @echo "Open htmlcov/index.html in your browser to view the detailed report" 60 | 61 | publish: 62 | twine upload --repository $(PYPI_REPO) dist/* 63 | 64 | docs: install 65 | mkdocs build 66 | 67 | docs_serve: docs 68 | mkdocs serve 69 | 70 | docs_push: docs 71 | mkdocs gh-deploy --force 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Redis Panel 2 | 3 | [![Tests](https://github.com/yassi/dj-redis-panel/actions/workflows/test.yml/badge.svg)](https://github.com/yassi/dj-redis-panel/actions/workflows/test.yml) 4 | [![codecov](https://codecov.io/gh/yassi/dj-redis-panel/branch/main/graph/badge.svg)](https://codecov.io/gh/yassi/dj-redis-panel) 5 | [![PyPI version](https://badge.fury.io/py/dj-redis-panel.svg)](https://badge.fury.io/py/dj-redis-panel) 6 | [![Python versions](https://img.shields.io/pypi/pyversions/dj-redis-panel.svg)](https://pypi.org/project/dj-redis-panel/) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | 9 | A Django Admin panel for browsing, inspecting, and managing Redis keys. No postgres/mysql models or changes required. 10 | 11 | ![Django Redis Panel - Instance List](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/instances_list.png) 12 | 13 | ## Docs 14 | 15 | [https://yassi.github.io/dj-redis-panel/](https://yassi.github.io/dj-redis-panel/) 16 | 17 | ## Features 18 | 19 | - **Browse Redis Keys**: Search and filter Redis keys with pattern matching 20 | - **Instance Overview**: Monitor Redis instance metrics and database statistics 21 | - **Key Management**: View, edit, and delete Redis keys with support for all data types 22 | - **Feature Toggles**: Granular control over operations (delete, edit, TTL updates) 23 | - **Pagination**: Both traditional page-based and cursor-based pagination support 24 | - **Django Admin Integration**: Seamless integration with Django admin styling and dark mode 25 | - **Permission Control**: Respects Django admin permissions and staff-only access 26 | - **Multiple Instances**: Support for multiple Redis instances with different configurations 27 | 28 | ## Supported Redis Data Types 29 | 30 | - **String**: View and edit string values 31 | - **List**: Browse list items with pagination 32 | - **Set**: View set members 33 | - **Hash**: Display hash fields and values in a table format 34 | - **Sorted Set**: Show sorted set members with scores 35 | 36 | ### Project Structure 37 | 38 | ``` 39 | dj-redis-panel/ 40 | ├── dj_redis_panel/ # Main package 41 | │ ├── templates/ # Django templates 42 | │ ├── redis_utils.py # Redis utilities 43 | │ ├── views.py # Django views 44 | │ └── urls.py # URL patterns 45 | ├── example_project/ # Example Django project 46 | ├── tests/ # Test suite 47 | ├── images/ # Screenshots for README 48 | └── requirements.txt # Development dependencies 49 | ``` 50 | 51 | ## Requirements 52 | 53 | - Python 3.9+ 54 | - Django 4.2+ 55 | - Redis 4.0+ 56 | - redis-py 4.0+ 57 | 58 | 59 | 60 | ## Screenshots 61 | 62 | ### Django Admin Integration 63 | Seamlessly integrated into your Django admin interface. A new section for dj-redis-panel 64 | will appear in the same places where your models appear. 65 | 66 | **NOTE:** This application does not actually introduce any model or migrations. 67 | 68 | ![Admin Home](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/admin_home.png) 69 | 70 | ### Instance Overview 71 | Monitor your Redis instances with detailed metrics and database information. 72 | 73 | ![Instance Overview](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/instance_overview.png) 74 | 75 | ### Key Search - Page-based Pagination 76 | Search for keys with traditional page-based navigation. 77 | 78 | ![Key Search - Page Index](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_search_page_index.png) 79 | 80 | ### Key Search - Cursor-based Pagination 81 | Efficient cursor-based pagination for large datasets. 82 | 83 | ![Key Search - Cursor](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_search_cursor.png) 84 | 85 | ### Key Detail - String Values 86 | View and edit string key values with TTL management. 87 | 88 | ![Key Detail - String](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_detail_string.png) 89 | 90 | ### Key Detail - Other data structures 91 | Browse keys with more complex data structures such as hashes, lists, etc. 92 | 93 | ![Key Detail - Hash](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_detail_hash.png) 94 | 95 | 96 | ## Installation 97 | 98 | ### 1. Install the Package 99 | 100 | ```bash 101 | pip install dj-redis-panel 102 | ``` 103 | 104 | ### 2. Add to Django Settings 105 | 106 | Add `dj_redis_panel` to your `INSTALLED_APPS`: 107 | 108 | ```python 109 | INSTALLED_APPS = [ 110 | 'django.contrib.admin', 111 | 'django.contrib.auth', 112 | 'django.contrib.contenttypes', 113 | 'django.contrib.sessions', 114 | 'django.contrib.messages', 115 | 'django.contrib.staticfiles', 116 | 'dj_redis_panel', # Add this line 117 | # ... your other apps 118 | ] 119 | ``` 120 | 121 | ### 3. Configure Redis Instances 122 | 123 | Add the Django Redis Panel configuration to your Django settings: 124 | 125 | ```python 126 | DJ_REDIS_PANEL_SETTINGS = { 127 | # Global feature flags (can be overridden per instance) 128 | "ALLOW_KEY_DELETE": False, 129 | "ALLOW_KEY_EDIT": True, 130 | "ALLOW_TTL_UPDATE": True, 131 | "CURSOR_PAGINATED_SCAN": False, 132 | "CURSOR_PAGINATED_COLLECTIONS": False, 133 | 134 | "INSTANCES": { 135 | "default": { 136 | "description": "Default Redis Instance", 137 | "host": "127.0.0.1", 138 | "port": 6379, 139 | # Optional: override global settings for this instance 140 | "features": { 141 | "ALLOW_KEY_DELETE": True, 142 | "CURSOR_PAGINATED_SCAN": True, 143 | }, 144 | }, 145 | "other_instance": { 146 | "description": "Cache Redis Instance", 147 | "url": "rediss://127.0.0.1:6379", 148 | }, 149 | } 150 | } 151 | ``` 152 | 153 | ### 4. Include URLs 154 | 155 | Add the Redis Panel URLs to your main `urls.py`: 156 | 157 | ```python 158 | from django.contrib import admin 159 | from django.urls import path, include 160 | 161 | urlpatterns = [ 162 | path('admin/redis/', include('dj_redis_panel.urls')), # Add this line 163 | path('admin/', admin.site.urls), 164 | ] 165 | ``` 166 | 167 | ### 5. Run Migrations and Create Superuser 168 | 169 | ```bash 170 | python manage.py migrate 171 | python manage.py createsuperuser # If you don't have an admin user 172 | ``` 173 | 174 | ### 6. Access the Panel 175 | 176 | 1. Start your Django development server: 177 | ```bash 178 | python manage.py runserver 179 | ``` 180 | 181 | 2. Navigate to the Django admin at `http://127.0.0.1:8000/admin/` 182 | 183 | 3. Look for the "DJ_REDIS_PANEL" section in the admin interface 184 | 185 | 4. Click "Manage Redis keys and values" to start browsing your Redis instances 186 | 187 | ## Configuration Options 188 | 189 | The following options are set globally but can also be configured on a per instance basis: 190 | Note that settings using all caps are feature flags meant to affect how dj-redis-panel operates. 191 | settings using lower case names are actually settings that can be passed directly into the 192 | underlying redis client (redis-py) 193 | 194 | 195 | | Setting | Default | Description | 196 | |---------|---------|-------------| 197 | | `ALLOW_KEY_DELETE` | `False` | Allow deletion of Redis keys | 198 | | `ALLOW_KEY_EDIT` | `True` | Allow editing of key values | 199 | | `ALLOW_TTL_UPDATE` | `True` | Allow updating key TTL (expiration) | 200 | | `CURSOR_PAGINATED_SCAN` | `False` | Use cursor-based pagination instead of page-based | 201 | | `CURSOR_PAGINATED_COLLECTIONS` | `False` | Use cursor based pagination for key values like lists and hashs | 202 | | `socket_timeout` | 5.0 | timeout for redis opertation after established connection | 203 | | `socket_connect_timeout` | 3.0 | timeout for initial connection to redis instance | 204 | 205 | 206 | ### Instance Configuration 207 | 208 | Each Redis instance can be configured with: 209 | 210 | #### Connection via Host/Port: 211 | ```python 212 | "instance_name": { 213 | "description": "Human-readable description", 214 | "host": "127.0.0.1", 215 | "port": 6379, 216 | "socket_timeout": 1.0, # Optional: will use sane default 217 | "socket_connect_timeout": 1.0, # Optional: will use sane default 218 | "password": "password", # Optional 219 | "features": { # Optional: override global settings 220 | "ALLOW_KEY_DELETE": True, 221 | }, 222 | } 223 | ``` 224 | 225 | #### Connection via URL: 226 | ```python 227 | "instance_name": { 228 | "description": "Human-readable description", 229 | "url": "redis://user:password@host:port", 230 | "socket_timeout": 1.0, # Optional: will use sane default 231 | "socket_connect_timeout": 1.0, # Optional: will use sane default 232 | "features": { # Optional: override global settings 233 | "CURSOR_PAGINATED_SCAN": True, 234 | }, 235 | } 236 | ``` 237 | 238 | ## License 239 | 240 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 241 | 242 | --- 243 | 244 | ## Development Setup 245 | 246 | If you want to contribute to this project or set it up for local development: 247 | 248 | ### Prerequisites 249 | 250 | - Python 3.9 or higher 251 | - Redis server running locally 252 | - Git 253 | - Autoconf 254 | 255 | ### 1. Clone the Repository 256 | 257 | ```bash 258 | git clone https://github.com/yassi/dj-redis-panel.git 259 | cd dj-redis-panel 260 | ``` 261 | 262 | ### 2. Create Virtual Environment 263 | 264 | ```bash 265 | python -m venv venv 266 | source venv/bin/activate # On Windows: venv\Scripts\activate 267 | ``` 268 | 269 | ### 3. Install dj-redis-panel inside of your virtualenv 270 | 271 | A make file is included in the repository root with multiple commands for building 272 | and maintaining this project. The best approach is to start by using one of the 273 | package installation commands found below: 274 | ```bash 275 | # Install all dependencies and dj-redis-panel into your current env 276 | make install 277 | ``` 278 | 279 | ### 4. Set Up Example Project 280 | 281 | The repository includes an example Django project for development and testing: 282 | 283 | ```bash 284 | cd example_project 285 | python manage.py migrate 286 | python manage.py createsuperuser 287 | ``` 288 | 289 | ### 5. Populate Test Data (Optional) 290 | An optional CLI tool for populating redis keys automatically is included in the 291 | example django project in this code base. 292 | 293 | ```bash 294 | python manage.py populate_redis 295 | ``` 296 | 297 | This command will populate your Redis instance with sample data for testing. 298 | 299 | ### 6. Run the Development Server 300 | 301 | ```bash 302 | python manage.py runserver 303 | ``` 304 | 305 | Visit `http://127.0.0.1:8000/admin/` to access the Django admin with Redis Panel. 306 | 307 | ### 7. Running Tests 308 | 309 | The project includes a comprehensive test suite. You can run them by using make or 310 | by invoking pytest directly: 311 | 312 | ```bash 313 | # build and install all dev dependencies and run all tests 314 | make test 315 | 316 | # Additionally generate coverage reports in multiple formats 317 | make test_coverage 318 | ``` 319 | 320 | **Note**: Tests require a running Redis server on `127.0.0.1:6379`. The tests use databases 13, 14, and 15 for isolation and automatically clean up after each test. 321 | 322 | ### 8. Dockerized Redis 323 | 324 | Test for this project (as well as any active development) require an active redis installation. 325 | Although not required, a docker-compose file is included to allow for easy creation of local 326 | redis instances. 327 | 328 | ```bash 329 | # Start Redis on localhost and the usual port 6379 330 | docker-compose up redis -d 331 | ``` 332 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # Codecov configuration 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: 80% 7 | threshold: 1% 8 | if_not_found: success 9 | patch: 10 | default: 11 | target: 80% 12 | threshold: 5% 13 | if_not_found: success 14 | 15 | comment: 16 | layout: "reach, diff, flags, files" 17 | behavior: default 18 | require_changes: false 19 | require_base: false 20 | require_head: true 21 | 22 | ignore: 23 | - "tests/" 24 | - "example_project/" 25 | - "setup.py" 26 | - "docs/" 27 | - "*.md" 28 | -------------------------------------------------------------------------------- /dj_redis_panel/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 4 | -------------------------------------------------------------------------------- /dj_redis_panel/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.http import HttpResponseRedirect 3 | from django.urls import reverse 4 | 5 | 6 | from .models import RedisPanelPlaceholder 7 | 8 | 9 | @admin.register(RedisPanelPlaceholder) 10 | class RedisPanelPlaceholderAdmin(admin.ModelAdmin): 11 | def changelist_view(self, request, extra_context=None): 12 | # The @staff_member_required decorator on the view will handle auth 13 | return HttpResponseRedirect(reverse("dj_redis_panel:index")) 14 | 15 | def has_add_permission(self, request): 16 | return False 17 | 18 | def has_change_permission(self, request, obj=None): 19 | # Allow staff members to "view" the Redis panel 20 | return request.user.is_staff 21 | 22 | def has_delete_permission(self, request, obj=None): 23 | return False 24 | 25 | def has_view_permission(self, request, obj=None): 26 | # Allow staff members to view the Redis panel 27 | return request.user.is_staff 28 | -------------------------------------------------------------------------------- /dj_redis_panel/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjRedisPanelConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "dj_redis_panel" 7 | -------------------------------------------------------------------------------- /dj_redis_panel/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class RedisPanelPlaceholder(models.Model): 5 | """ 6 | This is a fake model used to create an entry in the admin panel for the redis panel. 7 | When we register this app with the admin site, it is configured to simply load 8 | the redis panel templates. 9 | """ 10 | 11 | class Meta: 12 | managed = False 13 | verbose_name = "Django Redis Panel" 14 | verbose_name_plural = "Manage Redis keys and values" 15 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/base.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_list %} 3 | 4 | {% block title %}{{ title }} | DJ Redis Panel{% endblock %} 5 | 6 | {% block extrahead %} 7 | 10 | {% endblock %} 11 | 12 | {% block branding %} 13 |

DJ Redis Panel

14 | {% endblock %} 15 | 16 | {% block breadcrumbs %} 17 | 21 | {% endblock %} 22 | 23 | {% block content %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/dj_redis_panel/base.html" %} 2 | {% load i18n admin_urls static admin_list %} 3 | 4 | {% block content %} 5 |
6 |

{% trans 'Redis Instances' %}

7 |
8 | {% if redis_instances %} 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for instance in redis_instances %} 24 | 25 | 31 | 47 | 54 | 61 | 68 | 75 | 84 | 85 | {% endfor %} 86 | 87 |
{% trans 'Instance' %}{% trans 'Status' %}{% trans 'Version' %}{% trans 'Memory Used' %}{% trans 'Clients' %}{% trans 'Total Keys' %}{% trans 'Actions' %}
26 | {{ instance.alias }} 27 | {% if instance.config.description %} 28 |
{{ instance.config.description }} 29 | {% endif %} 30 |
32 | {% if instance.status == 'connected' %} 33 | 34 | Connected 35 | {% trans 'Connected' %} 36 | 37 | {% else %} 38 | 39 | Disconnected 40 | {% trans 'Disconnected' %} 41 | 42 | {% if instance.error %} 43 |
{{ instance.error }} 44 | {% endif %} 45 | {% endif %} 46 |
48 | {% if instance.info %} 49 | {{ instance.info.redis_version }} 50 | {% else %} 51 | 52 | {% endif %} 53 | 55 | {% if instance.info %} 56 | {{ instance.info.used_memory_human }} 57 | {% else %} 58 | 59 | {% endif %} 60 | 62 | {% if instance.info %} 63 | {{ instance.info.connected_clients }} 64 | {% else %} 65 | 66 | {% endif %} 67 | 69 | {% if instance.info %} 70 | {{ instance.total_keys }} 71 | {% else %} 72 | 73 | {% endif %} 74 | 76 | {% if instance.status == 'connected' %} 77 | 78 | {% trans 'Browse Instance' %} 79 | 80 | {% else %} 81 | {% trans 'N/A' %} 82 | {% endif %} 83 |
88 |
89 | {% else %} 90 |
91 |
92 |

{% trans 'Redis Configuration Required' %}

93 | 94 |
95 |
96 |

{% trans 'To use the Redis panel, you must configure Redis instances in your Django settings.' %}

97 |
98 |
99 | 100 |
101 |

{% trans 'Step 1: Configure Redis Panel Settings' %}

102 |
103 |

{% trans 'Add the following to your Django settings.py:' %}

104 |
105 |
106 |
107 | 130 |
131 |
132 |
133 | 134 | 135 | 136 |
137 |
138 |

{% trans 'After adding the configuration, restart your Django server to see your Redis instances.' %}

139 |
140 |
141 |
142 |
143 | {% endif %} 144 |
145 | {% endblock %} 146 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/instance_overview.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/dj_redis_panel/base.html" %} 2 | {% load i18n admin_urls static %} 3 | 4 | {% block breadcrumbs %} 5 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 | 15 |
16 |

{% trans 'System Information' %}

17 | {% if error_message %} 18 |
19 |

{% trans 'Connection Error:' %} {{ error_message }}

20 |
21 | {% elif hero_numbers %} 22 |
23 |
24 |
25 | {% trans 'Redis Version' %}
26 | {{ hero_numbers.version }} 27 |
28 |
29 | {% trans 'Memory Used' %}
30 | {{ hero_numbers.memory_used }} 31 |
32 |
33 | {% trans 'Peak Memory' %}
34 | {{ hero_numbers.memory_peak }} 35 |
36 |
37 | {% trans 'Connected Clients' %}
38 | {{ hero_numbers.connected_clients }} 39 |
40 |
41 | {% trans 'Uptime' %}
42 | {{ hero_numbers.uptime|floatformat:0 }}s 43 |
44 |
45 | {% trans 'Commands Processed' %}
46 | {{ hero_numbers.total_commands_processed|floatformat:0 }} 47 |
48 |
49 |
50 | {% endif %} 51 |
52 | 53 | 54 | {% if databases and not error_message %} 55 | 56 |
57 |

{% trans 'Databases' %}

58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {% for db in databases %} 72 | 73 | 79 | 86 | 93 | 100 | 109 | 110 | {% endfor %} 111 | 112 |
{% trans 'DB Name' %}{% trans 'Keys' %}{% trans 'Avg TTL (s)' %}{% trans 'Expires' %}{% trans 'Actions' %}
74 | DB {{ db.db_number }} 75 | {% if db.is_default %} 76 | {% trans 'default' %} 77 | {% endif %} 78 | 80 | {% if db.keys > 0 %} 81 | {{ db.keys|floatformat:0 }} 82 | {% else %} 83 | {% trans 'Empty' %} 84 | {% endif %} 85 | 87 | {% if db.avg_ttl > 0 %} 88 | {{ db.avg_ttl|floatformat:0 }}s 89 | {% else %} 90 | 91 | {% endif %} 92 | 94 | {% if db.expires > 0 %} 95 | {{ db.expires|floatformat:0 }} 96 | {% else %} 97 | 98 | {% endif %} 99 | 101 | {% if db.keys > 0 %} 102 | 103 | {% trans 'Browse Keys' %} 104 | 105 | {% else %} 106 | {% trans 'No keys' %} 107 | {% endif %} 108 |
113 |
114 |
115 | 116 | {% endif %} 117 | 118 |
119 | {% endblock %} 120 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_add.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/dj_redis_panel/base.html" %} 2 | {% load i18n admin_urls static %} 3 | 4 | {% block breadcrumbs %} 5 | 12 | {% endblock %} 13 | 14 | {% block content %} 15 |
16 | {% if success_message %} 17 |
18 |
{{ success_message }}
19 |
20 | {% endif %} 21 | 22 | {% if error_message %} 23 |
24 |
{{ error_message }}
25 |
26 | {% endif %} 27 | 28 |
29 |

{% trans 'Add New Key' %}

30 |
31 | 32 | {% if not allow_key_edit %} 33 |
34 |

{% trans 'Error:' %} {% trans 'Key creation is disabled for this instance.' %}

35 |

{% trans 'Back to Key Search' %}

36 |
37 | {% else %} 38 | 39 |
40 |

{% trans 'Redis Key Types:' %}

41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
{% trans 'Type' %}{% trans 'Description' %}{% trans 'Use Cases' %}
{% trans 'String' %}{% trans 'Simple text or binary data value' %}{% trans 'Caching, counters, flags, JSON data' %}
{% trans 'List' %}{% trans 'Ordered collection of strings' %}{% trans 'Queues, activity feeds, recent items' %}
{% trans 'Set' %}{% trans 'Unordered collection of unique strings' %}{% trans 'Tags, unique visitors, permissions' %}
{% trans 'Sorted Set' %}{% trans 'Ordered collection of unique strings with scores' %}{% trans 'Leaderboards, rankings, time-series data' %}
{% trans 'Hash' %}{% trans 'Collection of field-value pairs' %}{% trans 'User profiles, settings, object storage' %}
77 |

{% trans 'The key will be created with a placeholder item that you can edit or delete. After creation, you will be redirected to the key detail page where you can manage the content.' %}

78 |
79 |
80 |
81 | {% csrf_token %} 82 | 83 |
84 |
85 |
86 | 87 | 89 |
{% trans 'The name for your new Redis key. Must be unique within this database.' %}
90 |
91 |
92 |
93 | 94 |
95 |
96 | 97 | 114 |
{% trans 'Select the type of Redis data structure for this key.' %}
115 |
116 |
117 |
118 | 119 |
120 | 121 |
122 |
123 | {% endif %} 124 |
125 |
126 | {% endblock %} 127 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/dj_redis_panel/base.html" %} 2 | {% load i18n admin_urls static %} 3 | 4 | {% block breadcrumbs %} 5 | 12 | {% endblock %} 13 | 14 | {% block content %} 15 |
16 | {% if success_message %} 17 |
18 |
{{ success_message }}
19 |
20 | {% endif %} 21 | 22 | {% if error_message %} 23 |
24 |
{{ error_message }}
25 |
26 | {% endif %} 27 | 28 | {% if key_data %} 29 |
30 | 31 |
32 |

KEY: {{ key_data.name }}

33 | {% if allow_key_delete %} 34 |
35 | {% csrf_token %} 36 | 37 | 38 |
39 | {% else %} 40 | 41 | {% endif %} 42 |
43 | 44 | 45 |
46 |

{% trans 'Key Information' %}

47 |
48 |
49 |
50 | 51 |
52 | {{ key_data.name }} 53 |
54 |
55 |
56 | 57 |
58 |
59 | 60 |
61 | {{ key_data.type|upper }} 62 |
63 |
64 |
65 | 66 |
67 |
68 | 69 |
70 | {% if key_data.type == "string" %} 71 | {{ key_data.size }} {% trans 'bytes' %} 72 | {% else %} 73 | {{ key_data.size }} {% trans 'elements' %} 74 | {% endif %} 75 |
76 |
77 |
78 | 79 |
80 |
81 | 82 |
83 | {% if key_data.ttl %} 84 | {{ key_data.ttl }} {% trans 'seconds' %} 85 | {% else %} 86 | {% trans 'No expiration' %} 87 | {% endif %} 88 |
89 |
90 |
91 |
92 | 93 | 94 |
95 |

{% trans 'TTL Management' %}

96 |
97 | {% if allow_ttl_update %} 98 |
99 | {% csrf_token %} 100 | 101 |
102 |
103 | 104 | 108 |
109 | {% trans 'Set number of seconds until key expires. Leave empty or use -1 to remove expiration.' %} 110 |
111 |
112 |
113 | 114 |
115 |
116 |
117 | {% else %} 118 |
119 |
120 | 121 | 125 |
126 | {% trans 'TTL updates are disabled for this instance.' %} 127 |
128 |
129 |
130 | 131 |
132 |
133 | {% endif %} 134 |
135 | 136 | 137 | {% include "admin/dj_redis_panel/key_detail_add.html" %} 138 | 139 | 140 |
141 |

{% trans 'Key Value' %}

142 |
143 | 144 | {% if is_paginated and key_data.type != "string" %} 145 | 146 |
147 |
148 | 149 | 155 | {# Do not include current page/cursor when changing per_page - this resets to beginning #} 156 |
157 | {% if use_cursor_pagination %} 158 | {% trans 'Using cursor-based pagination for better performance' %} 159 | {% endif %} 160 |
161 | {% endif %} 162 |
163 | 164 | {% if key_data.type == "string" %} 165 | {% include "admin/dj_redis_panel/key_detail_value_string.html" %} 166 | 167 | {% elif key_data.type == "list" %} 168 | {% include "admin/dj_redis_panel/key_detail_value_list.html" %} 169 | 170 | {% elif key_data.type == "set" %} 171 | {% include "admin/dj_redis_panel/key_detail_value_set.html" %} 172 | 173 | {% elif key_data.type == "zset" %} 174 | {% include "admin/dj_redis_panel/key_detail_value_zset.html" %} 175 | 176 | {% elif key_data.type == "hash" %} 177 | {% include "admin/dj_redis_panel/key_detail_value_hash.html" %} 178 | 179 | {% else %} 180 |
181 |

{% trans 'Unsupported key type for display:' %} {{ key_data.type }}

182 |
183 | {% endif %} 184 |
185 | 186 | 187 |
188 | {% endif %} 189 |
190 | {% endblock %} 191 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_add.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# Add elements template for all key types #} 3 | 4 |
5 |

{% trans 'Add Elements' %}

6 |
7 | 8 | {% if not allow_key_edit %} 9 |
10 |
11 | {% trans 'Element adding is disabled for this instance. Enable ALLOW_KEY_EDIT in your configuration to add elements to Redis keys.' %} 12 |
13 |
14 |
15 | {% endif %} 16 | 17 | {% if key_data.type == "string" %} 18 | 19 |
20 |
21 | {% trans 'String keys do not support adding elements. Use the value editing section to modify the string content or add a new key and string value.' %} 22 |
23 |
24 | 25 | {% elif key_data.type == "list" %} 26 | 27 |
28 | {% csrf_token %} 29 | 30 |
31 |
32 | 33 | 34 |
{% trans 'Enter the value to add to the list.' %}
35 |
36 |
37 | 38 | 42 |
{% trans 'Choose where to add the new item in the list.' %}
43 |
44 |
45 |
46 | 47 |
48 |
49 | 50 | {% elif key_data.type == "set" %} 51 | 52 |
53 | {% csrf_token %} 54 | 55 |
56 |
57 | 58 | 59 |
{% trans 'Enter the member to add to the set. Duplicates will be ignored.' %}
60 |
61 |
62 |
63 | 64 |
65 |
66 | 67 | {% elif key_data.type == "zset" %} 68 | 69 |
70 | {% csrf_token %} 71 | 72 |
73 |
74 | 75 | 76 |
{% trans 'Enter the numeric score for the member.' %}
77 |
78 |
79 | 80 | 81 |
{% trans 'Enter the member to add to the sorted set.' %}
82 |
83 |
84 |
85 | 86 |
87 |
88 | 89 | {% elif key_data.type == "hash" %} 90 | 91 |
92 | {% csrf_token %} 93 | 94 |
95 |
96 | 97 | 98 |
{% trans 'Enter the field name for the hash.' %}
99 |
100 |
101 | 102 | 103 |
{% trans 'Enter the value for the field.' %}
104 |
105 |
106 |
107 | 108 |
109 |
110 | 111 | {% else %} 112 | 113 |
114 |
115 | {% trans 'Adding elements is not supported for this key type:' %} {{ key_data.type }} 116 |
117 |
118 | {% endif %} 119 | 120 |
121 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_pagination.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# Reusable pagination controls for key detail collections #} 3 | {# Parameters: collection_type (for labeling), position (top/bottom) #} 4 | 5 |
6 |

7 | {% if use_cursor_pagination %} 8 | {% if has_more or current_cursor > 0 %} 9 | {% if range_start and range_end %} 10 | {% if collection_type == "hash" %} 11 | Showing fields {{ range_start }} - {{ range_end }} of {{ key_data.size }} 12 | {% else %} 13 | Showing items {{ range_start }} - {{ range_end }} of {{ key_data.size }} 14 | {% endif %} 15 | {% else %} 16 | {% if collection_type == "hash" %} 17 | Showing {{ showing_count }} of {{ key_data.size }} fields (cursor: {{ current_cursor }}) 18 | {% else %} 19 | Showing {{ showing_count }} of {{ key_data.size }} items (cursor: {{ current_cursor }}) 20 | {% endif %} 21 | {% endif %} 22 |
23 | {% if current_cursor > 0 %} 24 | 25 | {% endif %} 26 | {% if has_more %} 27 | 28 | {% endif %} 29 | {% else %} 30 | {% if collection_type == "hash" %} 31 | Showing all {{ key_data.size }} fields 32 | {% else %} 33 | Showing all {{ key_data.size }} items 34 | {% endif %} 35 | {% endif %} 36 | {% else %} 37 | {% if total_pages > 1 %} 38 | Page {{ current_page }} of {{ total_pages }} 39 | {% if collection_type == "hash" %} 40 | (showing {{ showing_count }} of {{ key_data.size }} fields) 41 | {% else %} 42 | (showing {{ showing_count }} of {{ key_data.size }} items) 43 | {% endif %} 44 |
45 | {% if has_previous %} 46 | 47 | {% endif %} 48 | 49 | {% for num in page_range %} 50 | {% if num == "..." %} 51 | … 52 | {% elif num == current_page %} 53 | {{ num }} 54 | {% else %} 55 | {{ num }} 56 | {% endif %} 57 | {% endfor %} 58 | 59 | {% if has_next %} 60 | 61 | {% endif %} 62 | {% else %} 63 | {% if collection_type == "hash" %} 64 | Showing all {{ key_data.size }} fields 65 | {% else %} 66 | Showing all {{ key_data.size }} items 67 | {% endif %} 68 | {% endif %} 69 | {% endif %} 70 |

71 |
72 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_pagination_info.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# Pagination info display (without navigation controls) #} 3 | 4 | {% if use_cursor_pagination %} 5 | {% if range_start and range_end %} 6 |

7 | {% blocktrans with start=range_start end=range_end total=key_data.size %}Showing {{ collection_type|default:"items" }} {{ start }} - {{ end }} of {{ total }}{% endblocktrans %} 8 |

9 | {% else %} 10 |

11 | {% blocktrans with count=showing_count total=key_data.size cursor=current_cursor %}Showing {{ count }} of {{ total }} {{ collection_type|default:"items" }} (cursor: {{ cursor }}){% endblocktrans %} 12 |

13 | {% endif %} 14 | {% else %} 15 | {% if total_pages > 1 %} 16 |

17 | {% blocktrans with page=current_page total_pages=total_pages start=start_index end=end_index total=key_data.size %}Page {{ page }} of {{ total_pages }} - Showing {{ collection_type|default:"items" }} {{ start }} - {{ end }} of {{ total }}{% endblocktrans %} 18 |

19 | {% endif %} 20 | {% endif %} 21 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_value_hash.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# Hash value display template #} 3 | 4 | 5 |
6 |

{% trans 'Hash Fields' %} ({{ key_data.size }} {% trans 'fields' %})

7 | 8 | {% if is_paginated %} 9 | 10 | {% include "admin/dj_redis_panel/key_detail_pagination_info.html" with collection_type="fields" %} 11 | {% endif %} 12 | 13 | {% if key_data.value %} 14 | 15 | 16 | 17 | 18 | 19 | {% if allow_key_edit %} 20 | 21 | {% endif %} 22 | 23 | 24 | 25 | {% for field, value in key_data.value.items %} 26 | 27 | 28 | 43 | {% if allow_key_edit %} 44 | 55 | {% endif %} 56 | 57 | {% endfor %} 58 | 59 |
{% trans 'Field' %}{% trans 'Value' %}{% trans 'Actions' %}
{{ field }} 29 | {% if allow_key_edit %} 30 |
31 | {% csrf_token %} 32 | 33 | 34 |
35 | 36 | 37 |
38 |
39 | {% else %} 40 | {{ value }} 41 | {% endif %} 42 |
45 |
47 | {% csrf_token %} 48 | 49 | 50 | 53 |
54 |
60 | 61 | {% if is_paginated %}{% if has_more or total_pages > 1 %} 62 | 63 | {% include "admin/dj_redis_panel/key_detail_pagination.html" with collection_type="hash" position="controls" %} 64 | {% endif %}{% endif %} 65 | 66 | {% else %} 67 |

{% trans 'Hash is empty' %}

68 | {% endif %} 69 |
70 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_value_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# List value display template #} 3 | 4 | 5 |
6 |

{% trans 'List Elements' %} ({{ key_data.size }} {% trans 'items' %})

7 | 8 | {% if is_paginated %} 9 | 10 | {% include "admin/dj_redis_panel/key_detail_pagination_info.html" with collection_type="items" %} 11 | {% endif %} 12 | 13 | {% if key_data.value %} 14 | 15 | 16 | 17 | 18 | 19 | {% if allow_key_edit %} 20 | 21 | {% endif %} 22 | 23 | 24 | 25 | {% for item in key_data.value %} 26 | 27 | 28 | 43 | {% if allow_key_edit %} 44 | 55 | {% endif %} 56 | 57 | {% endfor %} 58 | 59 |
{% trans 'Index' %}{% trans 'Value' %}{% trans 'Actions' %}
{% if is_paginated %}{{ start_index|add:forloop.counter0 }}{% else %}{{ forloop.counter0 }}{% endif %} 29 | {% if allow_key_edit %} 30 |
31 | {% csrf_token %} 32 | 33 | 34 |
35 | 36 | 37 |
38 |
39 | {% else %} 40 | {{ item }} 41 | {% endif %} 42 |
45 |
47 | {% csrf_token %} 48 | 49 | 50 | 53 |
54 |
60 | 61 | {% if is_paginated %}{% if has_more or total_pages > 1 %} 62 | 63 | {% include "admin/dj_redis_panel/key_detail_pagination.html" with collection_type="list" position="controls" %} 64 | {% endif %}{% endif %} 65 | 66 | {% else %} 67 |

{% trans 'List is empty' %}

68 | {% endif %} 69 |
70 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_value_set.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# Set value display template #} 3 | 4 | 5 |
6 |

{% trans 'Set Members' %} ({{ key_data.size }} {% trans 'items' %})

7 | 8 | {% if is_paginated %} 9 | 10 | {% include "admin/dj_redis_panel/key_detail_pagination_info.html" with collection_type="members" %} 11 | {% endif %} 12 | 13 | {% if key_data.value %} 14 | 15 | 16 | 17 | 18 | {% if allow_key_edit %} 19 | 20 | {% endif %} 21 | 22 | 23 | 24 | {% for member in key_data.value %} 25 | 26 | 27 | {% if allow_key_edit %} 28 | 39 | {% endif %} 40 | 41 | {% endfor %} 42 | 43 |
{% trans 'Member' %}{% trans 'Delete' %}
{{ member }} 29 |
31 | {% csrf_token %} 32 | 33 | 34 | 37 |
38 |
44 | 45 | {% if is_paginated %}{% if has_more or total_pages > 1 %} 46 | 47 | {% include "admin/dj_redis_panel/key_detail_pagination.html" with collection_type="set" position="controls" %} 48 | {% endif %}{% endif %} 49 | 50 | {% else %} 51 |

{% trans 'Set is empty' %}

52 | {% endif %} 53 |
54 | {% trans 'Set editing is not supported in this interface. Use Redis CLI or other tools for complex set operations.' %} 55 |
56 |
57 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_value_string.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# String value display and editing template #} 3 | 4 | 5 |

{% trans 'String Value' %}

6 |
7 | {% if allow_key_edit %} 8 |
9 | {% csrf_token %} 10 | 11 |
12 |
13 | 14 |
{% trans 'Edit the string value directly.' %}
15 |
16 |
17 | 18 |
19 |
20 |
21 | {% else %} 22 |
23 |
24 | 25 |
{% trans 'Value editing is disabled for this instance.' %}
26 |
27 |
28 | 29 |
30 |
31 | {% endif %} 32 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_detail_value_zset.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {# Sorted Set value display template #} 3 | 4 | 5 |
6 |

{% trans 'Sorted Set Members' %} ({{ key_data.size }} {% trans 'items' %})

7 | 8 | {% if is_paginated %} 9 | 10 | {% include "admin/dj_redis_panel/key_detail_pagination_info.html" with collection_type="members" %} 11 | {% endif %} 12 | 13 | {% if key_data.value %} 14 | 15 | 16 | 17 | 18 | 19 | {% if allow_key_edit %} 20 | 21 | {% endif %} 22 | 23 | 24 | 25 | {% for member, score in key_data.value %} 26 | 27 | 28 | 43 | {% if allow_key_edit %} 44 | 55 | {% endif %} 56 | 57 | {% endfor %} 58 | 59 |
{% trans 'Member' %}{% trans 'Score' %}{% trans 'Actions' %}
{{ member }} 29 | {% if allow_key_edit %} 30 |
31 | {% csrf_token %} 32 | 33 | 34 |
35 | 36 | 37 |
38 |
39 | {% else %} 40 | {{ score }} 41 | {% endif %} 42 |
45 |
47 | {% csrf_token %} 48 | 49 | 50 | 53 |
54 |
60 | 61 | {% if is_paginated %}{% if has_more or total_pages > 1 %} 62 | 63 | {% include "admin/dj_redis_panel/key_detail_pagination.html" with collection_type="zset" position="controls" %} 64 | {% endif %}{% endif %} 65 | 66 | {% else %} 67 |

{% trans 'Sorted set is empty' %}

68 | {% endif %} 69 |
70 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/key_search.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/dj_redis_panel/base.html" %} 2 | {% load i18n admin_urls static %} 3 | 4 | 5 | {% block breadcrumbs %} 6 | 12 | {% endblock %} 13 | 14 | 15 | {% block content %} 16 | 17 |
18 | 19 |
20 | {% if use_cursor_pagination %} 21 | 22 | 📄 {% trans 'CURSOR PAGINATION' %} 23 | 24 | {% else %} 25 | 26 | 🔢 {% trans 'PAGE PAGINATION' %} 27 | 28 | {% endif %} 29 |
30 | 31 | 32 |
33 | 36 |
37 |
38 | 39 | 40 | 41 | 215 | {% endblock %} 216 | -------------------------------------------------------------------------------- /dj_redis_panel/templates/admin/dj_redis_panel/styles.css: -------------------------------------------------------------------------------- 1 | /* Table styles */ 2 | #result_list { 3 | width: 100%; 4 | } 5 | 6 | /* Icon styles */ 7 | .icon-yes img, 8 | .icon-no img { 9 | width: 13px; 10 | height: 13px; 11 | margin-right: 4px; 12 | vertical-align: text-bottom; 13 | } 14 | 15 | .icon-yes { 16 | color: #30a030; 17 | } 18 | 19 | .icon-no { 20 | color: #ba2121; 21 | } 22 | 23 | /* Message list styles */ 24 | .messagelist { 25 | margin: 0 0 20px 0; 26 | } 27 | 28 | .messagelist .success, 29 | .messagelist .error { 30 | padding: 10px 15px; 31 | border-radius: 4px; 32 | margin-bottom: 10px; 33 | border: 1px solid; 34 | } 35 | 36 | .messagelist .success { 37 | background-color: var(--message-success-bg, #d4edda); 38 | color: var(--body-fg, #155724); 39 | border-color: var(--message-success-border, #c3e6cb); 40 | } 41 | 42 | .messagelist .error { 43 | background-color: var(--message-error-bg, #f8d7da); 44 | color: var(--message-error-fg, #721c24); 45 | border-color: var(--message-error-border, #f5c6cb); 46 | } 47 | 48 | /* ===== INSTANCE OVERVIEW STYLES ===== */ 49 | 50 | .instance-stats { 51 | padding-top: 15px; 52 | padding-bottom: 15px; 53 | border-radius: 4px; 54 | margin-bottom: 20px; 55 | margin-top: 10px; 56 | } 57 | 58 | .stats-grid { 59 | display: grid; 60 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 61 | gap: 15px; 62 | } 63 | 64 | .stat-item { 65 | text-align: center; 66 | padding: 10px; 67 | border-radius: 3px; 68 | border: 1px solid var(--border-color, #ddd); 69 | } 70 | 71 | .stat-value { 72 | font-size: 18px; 73 | font-weight: bold; 74 | color: var(--link-fg, #417690); 75 | } 76 | 77 | .default-badge { 78 | background: var(--link-fg, #417690); 79 | color: var(--body-bg, white); 80 | padding: 2px 6px; 81 | border-radius: 3px; 82 | font-size: 10px; 83 | margin-left: 5px; 84 | } 85 | 86 | .ttl-value { 87 | font-weight: bold; 88 | color: var(--link-fg, #417690); 89 | } 90 | 91 | /* ===== KEY SEARCH STYLES ===== */ 92 | 93 | .search-form { 94 | padding: 15px; 95 | border-radius: 4px; 96 | margin-bottom: 20px; 97 | border: 1px solid var(--border-color, #ddd); 98 | } 99 | 100 | .search-form .form-row { 101 | margin: 0; 102 | } 103 | 104 | .search-form label { 105 | display: block; 106 | font-weight: bold; 107 | margin-bottom: 5px; 108 | } 109 | 110 | .search-form .help { 111 | font-size: 11px; 112 | margin-top: 3px; 113 | opacity: 0.7; 114 | } 115 | 116 | /* Key type badges - used in both search and detail views */ 117 | .key-type { 118 | font-size: 10px; 119 | font-weight: bold; 120 | padding: 2px 6px; 121 | border-radius: 3px; 122 | color: white; 123 | } 124 | 125 | .key-type-badge { 126 | display: inline-block; 127 | padding: 3px 8px; 128 | border-radius: 3px; 129 | font-size: 11px; 130 | font-weight: bold; 131 | color: white; 132 | } 133 | 134 | /* Key type colors - consistent across templates */ 135 | .key-type-string { 136 | background-color: #28a745; 137 | } 138 | 139 | .key-type-list { 140 | background-color: #007bff; 141 | } 142 | 143 | .key-type-set { 144 | background-color: #ffc107; 145 | } 146 | 147 | .key-type-zset { 148 | background-color: #17a2b8; 149 | } 150 | 151 | .key-type-hash { 152 | background-color: #6f42c1; 153 | } 154 | 155 | .key-type-stream { 156 | background-color: #059669; 157 | } 158 | 159 | /* Alternative key type colors used in search view */ 160 | .key-search .key-type-string { 161 | background: #417690; 162 | } 163 | 164 | .key-search .key-type-list { 165 | background: #30a030; 166 | } 167 | 168 | .key-search .key-type-set { 169 | background: #e47911; 170 | } 171 | 172 | .key-search .key-type-zset { 173 | background: #7b68ee; 174 | } 175 | 176 | .key-search .key-type-hash { 177 | background: #dc2626; 178 | } 179 | 180 | /* ===== KEY DETAIL STYLES ===== */ 181 | 182 | .key-value-table { 183 | width: 100%; 184 | border-collapse: collapse; 185 | margin: 10px 0; 186 | } 187 | 188 | .key-value-table th, 189 | .key-value-table td { 190 | padding: 8px 12px; 191 | border: 1px solid var(--border-color, #ddd); 192 | text-align: left; 193 | } 194 | 195 | .key-value-table th { 196 | font-weight: bold; 197 | background-color: var(--darkened-bg, rgba(0, 0, 0, 0.05)); 198 | } 199 | 200 | .key-value-table tbody tr:nth-child(even) { 201 | background-color: var(--darkened-bg, rgba(0, 0, 0, 0.02)); 202 | } 203 | 204 | .key-value-table code { 205 | padding: 2px 4px; 206 | border-radius: 3px; 207 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 208 | font-size: 12px; 209 | background-color: var(--darkened-bg, rgba(0, 0, 0, 0.05)); 210 | } 211 | 212 | .string-value-field { 213 | width: 100%; 214 | } 215 | 216 | .readonly { 217 | padding: 8px 0; 218 | } 219 | 220 | .readonly code { 221 | padding: 4px 8px; 222 | border-radius: 3px; 223 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 224 | font-size: 13px; 225 | background-color: var(--darkened-bg, rgba(0, 0, 0, 0.05)); 226 | } 227 | 228 | .danger-zone { 229 | border: 2px solid #dc3545; 230 | border-radius: 4px; 231 | background-color: var(--error-bg, rgba(220, 53, 69, 0.1)); 232 | } 233 | 234 | .danger-zone h2 { 235 | color: #dc3545; 236 | } 237 | 238 | .field-box { 239 | margin-bottom: 15px; 240 | } 241 | 242 | .submit-row { 243 | margin-top: 10px; 244 | } 245 | 246 | .key-value-display h3 { 247 | margin: 0 0 15px 0; 248 | opacity: 0.8; 249 | } 250 | 251 | /* Delete button styling to match Django admin */ 252 | .delete-button { 253 | background-color: #ba2121 !important; 254 | color: white !important; 255 | border-color: #ba2121 !important; 256 | } 257 | 258 | .delete-button:hover, 259 | .delete-button:focus { 260 | background-color: #a41e1e !important; 261 | border-color: #a41e1e !important; 262 | color: white !important; 263 | } 264 | 265 | /* Disabled delete button styling */ 266 | .delete-button:disabled { 267 | background-color: #ba2121 !important; 268 | color: rgba(255, 255, 255, 0.6) !important; 269 | border-color: #ba2121 !important; 270 | opacity: 0.6 !important; 271 | cursor: not-allowed !important; 272 | } 273 | 274 | .delete-button:disabled:hover, 275 | .delete-button:disabled:focus { 276 | background-color: #ba2121 !important; 277 | border-color: #ba2121 !important; 278 | color: rgba(255, 255, 255, 0.6) !important; 279 | opacity: 0.6 !important; 280 | } 281 | 282 | /* Dark mode support */ 283 | @media (prefers-color-scheme: dark) { 284 | .delete-button { 285 | background-color: #cc2936 !important; 286 | border-color: #cc2936 !important; 287 | } 288 | 289 | .delete-button:hover, 290 | .delete-button:focus { 291 | background-color: #b02329 !important; 292 | border-color: #b02329 !important; 293 | } 294 | 295 | .delete-button:disabled { 296 | background-color: #cc2936 !important; 297 | border-color: #cc2936 !important; 298 | color: rgba(255, 255, 255, 0.6) !important; 299 | opacity: 0.6 !important; 300 | } 301 | 302 | .delete-button:disabled:hover, 303 | .delete-button:disabled:focus { 304 | background-color: #cc2936 !important; 305 | border-color: #cc2936 !important; 306 | color: rgba(255, 255, 255, 0.6) !important; 307 | opacity: 0.6 !important; 308 | } 309 | } 310 | 311 | /* Pagination Mode Indicator Styles */ 312 | .pagination-mode-indicator { 313 | margin-bottom: 10px; 314 | } 315 | 316 | .pagination-mode-indicator .badge { 317 | display: inline-block; 318 | padding: 3px 8px; 319 | border-radius: 4px; 320 | font-size: 10px; 321 | font-weight: bold; 322 | text-transform: uppercase; 323 | letter-spacing: 0.5px; 324 | } 325 | 326 | .pagination-mode-indicator .badge.cursor-mode { 327 | background-color: #2196F3; 328 | color: white; 329 | } 330 | 331 | .pagination-mode-indicator .badge.page-mode { 332 | background-color: #4CAF50; 333 | color: white; 334 | } 335 | 336 | /* Dark mode support for badges */ 337 | @media (prefers-color-scheme: dark) { 338 | .pagination-mode-indicator .badge.cursor-mode { 339 | background-color: #1976D2; 340 | } 341 | 342 | .pagination-mode-indicator .badge.page-mode { 343 | background-color: #388E3C; 344 | } 345 | 346 | /* Inline edit styles for dark mode */ 347 | .inline-edit-input { 348 | border-color: #555; 349 | background-color: var(--darkened-bg, #2d2d2d); 350 | color: var(--body-fg, #fff); 351 | } 352 | 353 | .inline-edit-input:focus { 354 | border-color: #79aec8; 355 | } 356 | } 357 | 358 | /* Search Form Alignment Styles */ 359 | .search-form-row { 360 | margin-bottom: 15px; 361 | } 362 | 363 | .search-form-fields { 364 | display: flex; 365 | gap: 15px; 366 | align-items: end; 367 | margin-bottom: 5px; 368 | } 369 | 370 | .search-field-group { 371 | display: flex; 372 | flex-direction: column; 373 | gap: 3px; 374 | } 375 | 376 | .search-field-group label { 377 | font-weight: bold; 378 | color: #666; 379 | font-size: 11px; 380 | text-transform: uppercase; 381 | margin-bottom: 3px; 382 | white-space: nowrap; 383 | } 384 | 385 | .search-field-group input[type="text"], 386 | .search-field-group select { 387 | height: 32px; 388 | box-sizing: border-box; 389 | } 390 | 391 | .search-field-group select { 392 | min-width: 80px; 393 | padding: 6px 8px; 394 | } 395 | 396 | .search-field-group input[type="submit"] { 397 | height: 32px; 398 | padding: 6px 15px; 399 | box-sizing: border-box; 400 | } 401 | 402 | .search-help { 403 | margin-left: 0; 404 | margin-top: 5px; 405 | } 406 | 407 | .search-help .help { 408 | margin: 0; 409 | color: #666; 410 | font-size: 11px; 411 | font-style: italic; 412 | } 413 | 414 | /* ===== COLLECTION MEMBER DELETE STYLES ===== */ 415 | 416 | .delete-member-btn { 417 | background: none; 418 | border: none; 419 | cursor: pointer; 420 | font-size: 16px; 421 | padding: 4px 8px; 422 | border-radius: 3px; 423 | color: #dc3545; 424 | transition: all 0.2s ease; 425 | display: inline-flex; 426 | align-items: center; 427 | justify-content: center; 428 | } 429 | 430 | .delete-member-btn:hover:not(:disabled) { 431 | background-color: #dc3545; 432 | color: white; 433 | transform: scale(1.1); 434 | } 435 | 436 | .delete-member-btn:disabled { 437 | color: #ccc; 438 | cursor: not-allowed; 439 | opacity: 0.5; 440 | } 441 | 442 | .delete-member-btn:disabled:hover { 443 | background: none; 444 | transform: none; 445 | } 446 | 447 | /* ===== INLINE EDIT STYLES ===== */ 448 | 449 | .inline-edit-input { 450 | border: 1px solid #ddd; 451 | border-radius: 3px; 452 | padding: 4px 8px; 453 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 454 | font-size: 12px; 455 | background-color: var(--body-bg, #fff); 456 | color: var(--body-fg, #333); 457 | min-width: 100px; 458 | } 459 | 460 | .inline-edit-input:focus { 461 | outline: none; 462 | border-color: #79aec8; 463 | box-shadow: 0 0 0 2px rgba(121, 174, 200, 0.2); 464 | } 465 | 466 | .inline-edit-btn { 467 | background: #79aec8; 468 | border: none; 469 | border-radius: 3px; 470 | color: white; 471 | cursor: pointer; 472 | font-size: 14px; 473 | padding: 4px 8px; 474 | transition: all 0.2s ease; 475 | display: inline-flex; 476 | align-items: center; 477 | justify-content: center; 478 | min-width: 32px; 479 | height: 28px; 480 | } 481 | 482 | .inline-edit-btn:hover:not(:disabled) { 483 | background-color: #5b9bd5; 484 | transform: scale(1.05); 485 | } 486 | 487 | .inline-edit-btn:disabled { 488 | background-color: #ccc; 489 | cursor: not-allowed; 490 | opacity: 0.6; 491 | } 492 | 493 | .inline-edit-btn:disabled:hover { 494 | background-color: #ccc; 495 | transform: none; 496 | } 497 | -------------------------------------------------------------------------------- /dj_redis_panel/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | app_name = "dj_redis_panel" 5 | 6 | urlpatterns = [ 7 | path("", views.index, name="index"), 8 | path("/", views.instance_overview, name="instance_overview"), 9 | path( 10 | "/db//keys/", 11 | views.key_search, 12 | name="key_search", 13 | ), 14 | path( 15 | "/db//keys/add/", 16 | views.key_add, 17 | name="key_add", 18 | ), 19 | path( 20 | "/db//key/", 21 | views.KeyDetailView.as_view(), 22 | name="key_detail", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | redis: 5 | image: redis:7 6 | ports: 7 | - "6379:6379" 8 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | This page covers all the configuration options available in Django Redis Panel. 4 | 5 | ## Basic Configuration 6 | 7 | Django Redis Panel is configured through the `DJ_REDIS_PANEL_SETTINGS` dictionary in your Django settings file. 8 | 9 | ```python 10 | # settings.py 11 | DJ_REDIS_PANEL_SETTINGS = { 12 | # Global feature flags 13 | "ALLOW_KEY_DELETE": False, 14 | "ALLOW_KEY_EDIT": True, 15 | "ALLOW_TTL_UPDATE": True, 16 | "CURSOR_PAGINATED_SCAN": False, 17 | "CURSOR_PAGINATED_COLLECTIONS": False, 18 | # Redis instances configuration 19 | "INSTANCES": { 20 | # ... instance configurations 21 | } 22 | } 23 | ``` 24 | 25 | ## Global Settings 26 | 27 | These settings apply to all Redis instances unless overridden at the instance level. 28 | 29 | | Setting | Default | Description | 30 | |---------|---------|-------------| 31 | | `ALLOW_KEY_DELETE` | `False` | Allow deletion of Redis keys | 32 | | `ALLOW_KEY_EDIT` | `False` | Allow editing of key values | 33 | | `ALLOW_TTL_UPDATE` | `False` | Allow updating key TTL (expiration) | 34 | | `CURSOR_PAGINATED_SCAN` | `False` | Use cursor-based pagination instead of page-based | 35 | | `CURSOR_PAGINATED_COLLECTIONS` | `False` | Use cursor-based pagination for key values like lists and hashes | 36 | | `socket_timeout` | `5.0` | Socket timeout in seconds for Redis operations | 37 | | `socket_connect_timeout` | `3.0` | Connection timeout in seconds for establishing Redis connections | 38 | 39 | ### Feature Flags Details 40 | 41 | #### `ALLOW_KEY_DELETE` 42 | 43 | Controls whether users can delete Redis keys through the interface. 44 | 45 | - **`True`**: Shows delete buttons and allows key deletion 46 | - **`False`**: Hides delete functionality (recommended for production) 47 | 48 | !!! warning "Production Safety" 49 | It's recommended to set this to `False` in production environments to prevent accidental data loss. 50 | 51 | #### `ALLOW_KEY_EDIT` 52 | 53 | Controls whether users can modify Redis key values. 54 | 55 | - **`True`**: Allows editing of key values and collections (e.g. allow updates and deletes on lists, etc.) 56 | - **`False`**: Makes the interface read-only for key values 57 | 58 | #### `ALLOW_TTL_UPDATE` 59 | 60 | Controls whether users can modify key expiration times. 61 | 62 | - **`True`**: Shows TTL controls and allows expiration updates 63 | - **`False`**: Hides TTL modification functionality 64 | 65 | #### `CURSOR_PAGINATED_SCAN` 66 | 67 | Controls the pagination method for browsing keys. 68 | 69 | - **`True`**: Uses Redis SCAN command with cursor-based pagination (more efficient for large datasets) 70 | - **`False`**: Uses traditional page-based pagination with KEYS command 71 | 72 | !!! tip "Performance" 73 | Use cursor-based pagination (`True`) for Redis instances with many keys for better performance. 74 | 75 | #### `CURSOR_PAGINATED_COLLECTIONS` 76 | 77 | Controls the pagination method for key values such as lists, hashes, and sets 78 | 79 | - **`True`**: Uses cursor to paginate across large collection based values 80 | - **`False`**: Uses traditional page-based pagination 81 | 82 | !!! tip "Performance" 83 | For very large collections (e.g. keeping a very large leader board) use the cursor paginated 84 | method in order perform too many expensive queries on your instance. 85 | 86 | #### `socket_timeout` 87 | 88 | Controls how long to wait for Redis socket operations to complete. 89 | 90 | - **Default**: `5.0` seconds 91 | - **Purpose**: Prevents browser tabs from hanging indefinitely when Redis operations are slow 92 | - **Recommended values**: 93 | - Development: `5.0` - `10.0` seconds 94 | - Production: `3.0` - `5.0` seconds 95 | 96 | !!! info "Socket Timeout" 97 | This timeout applies to individual Redis commands after a connection has been established. If a Redis command takes longer than this timeout, it will fail with a timeout error instead of hanging the browser. 98 | 99 | #### `socket_connect_timeout` 100 | 101 | Controls how long to wait when establishing a connection to Redis. 102 | 103 | - **Default**: `3.0` seconds 104 | - **Purpose**: Prevents long waits when Redis instances are unreachable 105 | - **Recommended values**: 106 | - Local Redis: `1.0` - `3.0` seconds 107 | - Remote Redis: `3.0` - `5.0` seconds 108 | 109 | !!! info "Connection Timeout" 110 | This timeout applies only to the initial connection establishment. Once connected, `socket_timeout` governs individual operations. 111 | 112 | ## Instance Configuration 113 | 114 | Each Redis instance is configured under the `INSTANCES` key. You can define multiple instances with different settings. 115 | 116 | ### Connection Methods 117 | 118 | #### Host/Port Configuration 119 | 120 | ```python 121 | "instance_name": { 122 | "description": "Human-readable description", 123 | "host": "127.0.0.1", 124 | "port": 6379, 125 | "password": "password", # Optional 126 | } 127 | ``` 128 | 129 | #### URL Configuration 130 | 131 | ```python 132 | "instance_name": { 133 | "description": "Human-readable description", 134 | "url": "redis://user:password@host:port/db", 135 | } 136 | ``` 137 | 138 | #### SSL/TLS Configuration 139 | 140 | ```python 141 | "secure_instance": { 142 | "description": "Secure Redis Instance", 143 | "url": "rediss://user:password@host:6380", # Note: rediss:// for SSL 144 | } 145 | ``` 146 | 147 | ### Per-Instance Feature Overrides 148 | 149 | You can override global feature flags and timeout settings for individual instances: 150 | 151 | ```python 152 | "instance_name": { 153 | "description": "Production Redis", 154 | "host": "prod-redis.example.com", 155 | "port": 6379, 156 | "features": { 157 | "ALLOW_KEY_DELETE": False, # Override global setting 158 | "CURSOR_PAGINATED_SCAN": True, # Use cursor pagination for this instance 159 | }, 160 | # Instance-specific timeout overrides 161 | "socket_timeout": 10.0, # Allow longer operations for this instance 162 | "socket_connect_timeout": 5.0, # Allow more time to connect to remote server 163 | } 164 | ``` 165 | 166 | ## Complete Configuration Examples 167 | 168 | ### Development Environment 169 | 170 | ```python 171 | DJ_REDIS_PANEL_SETTINGS = { 172 | # Permissive settings for development 173 | "ALLOW_KEY_DELETE": True, 174 | "ALLOW_KEY_EDIT": True, 175 | "ALLOW_TTL_UPDATE": True, 176 | "CURSOR_PAGINATED_SCAN": False, 177 | 178 | # Relaxed timeouts for development 179 | "socket_timeout": 10.0, 180 | "socket_connect_timeout": 5.0, 181 | 182 | "INSTANCES": { 183 | "default": { 184 | "description": "Local Development Redis", 185 | "host": "127.0.0.1", 186 | "port": 6379, 187 | }, 188 | "cache": { 189 | "description": "Local Cache Redis", 190 | "host": "127.0.0.1", 191 | "port": 6379, 192 | }, 193 | } 194 | } 195 | ``` 196 | 197 | ### Production Environment 198 | 199 | ```python 200 | DJ_REDIS_PANEL_SETTINGS = { 201 | # Restrictive settings for production 202 | "ALLOW_KEY_DELETE": False, 203 | "ALLOW_KEY_EDIT": False, 204 | "ALLOW_TTL_UPDATE": False, 205 | "CURSOR_PAGINATED_SCAN": True, 206 | 207 | # Conservative timeouts for production 208 | "socket_timeout": 3.0, 209 | "socket_connect_timeout": 2.0, 210 | 211 | "INSTANCES": { 212 | "primary": { 213 | "description": "Primary Redis Cluster", 214 | "url": "rediss://user:password@redis-primary.example.com:6380/0", 215 | }, 216 | "cache": { 217 | "description": "Cache Redis Instance", 218 | "url": "rediss://user:password@redis-cache.example.com:6380/0", 219 | "features": { 220 | "ALLOW_KEY_EDIT": True, # Allow cache key editing 221 | }, 222 | # Allow slightly longer timeouts for cache operations 223 | "socket_timeout": 5.0, 224 | }, 225 | "sessions": { 226 | "description": "Session Storage", 227 | "url": "rediss://user:password@redis-sessions.example.com:6380/0", 228 | }, 229 | } 230 | } 231 | ``` 232 | 233 | ### Mixed Environment (Staging) 234 | 235 | ```python 236 | DJ_REDIS_PANEL_SETTINGS = { 237 | # Balanced settings for staging 238 | "ALLOW_KEY_DELETE": False, 239 | "ALLOW_KEY_EDIT": True, 240 | "ALLOW_TTL_UPDATE": True, 241 | "CURSOR_PAGINATED_SCAN": True, 242 | 243 | # Balanced timeouts for staging 244 | "socket_timeout": 5.0, 245 | "socket_connect_timeout": 3.0, 246 | 247 | "INSTANCES": { 248 | "staging": { 249 | "description": "Staging Redis", 250 | "host": "staging-redis.example.com", 251 | "port": 6379, 252 | "password": "staging-password", 253 | # Remote server may need longer connection timeout 254 | "socket_connect_timeout": 5.0, 255 | }, 256 | "debug": { 257 | "description": "Debug Redis (Full Access)", 258 | "host": "127.0.0.1", 259 | "port": 6379, 260 | "features": { 261 | "ALLOW_KEY_DELETE": True, # Allow deletion for debugging 262 | }, 263 | # Local debug instance can use faster timeouts 264 | "socket_timeout": 2.0, 265 | "socket_connect_timeout": 1.0, 266 | }, 267 | } 268 | } 269 | ``` 270 | 271 | ## Environment-Specific Configuration 272 | 273 | You can use different configurations based on your Django environment: 274 | 275 | ```python 276 | # settings.py 277 | import os 278 | 279 | # Base configuration 280 | DJ_REDIS_PANEL_SETTINGS = { 281 | "ALLOW_KEY_EDIT": True, 282 | "ALLOW_TTL_UPDATE": True, 283 | "CURSOR_PAGINATED_SCAN": True, 284 | # Default timeouts 285 | "socket_timeout": 5.0, 286 | "socket_connect_timeout": 3.0, 287 | "INSTANCES": { 288 | "default": { 289 | "description": "Default Redis", 290 | "host": os.getenv("REDIS_HOST", "127.0.0.1"), 291 | "port": int(os.getenv("REDIS_PORT", 6379)), 292 | "password": os.getenv("REDIS_PASSWORD"), 293 | } 294 | } 295 | } 296 | 297 | # Environment-specific overrides 298 | if os.getenv("DJANGO_ENV") == "production": 299 | DJ_REDIS_PANEL_SETTINGS["ALLOW_KEY_DELETE"] = False 300 | DJ_REDIS_PANEL_SETTINGS["ALLOW_KEY_EDIT"] = False 301 | # Stricter timeouts for production 302 | DJ_REDIS_PANEL_SETTINGS["socket_timeout"] = 3.0 303 | DJ_REDIS_PANEL_SETTINGS["socket_connect_timeout"] = 2.0 304 | else: 305 | DJ_REDIS_PANEL_SETTINGS["ALLOW_KEY_DELETE"] = True 306 | # More relaxed timeouts for development 307 | DJ_REDIS_PANEL_SETTINGS["socket_timeout"] = 10.0 308 | DJ_REDIS_PANEL_SETTINGS["socket_connect_timeout"] = 5.0 309 | ``` 310 | 311 | ## Configuration Validation 312 | 313 | Django Redis Panel validates your configuration on startup. Common validation errors include: 314 | 315 | - **Missing INSTANCES**: At least one Redis instance must be configured 316 | - **Invalid connection parameters**: Host/port or URL must be provided 317 | - **Connection failures**: Redis instances must be accessible 318 | 319 | ## Security Considerations 320 | 321 | ### Production Recommendations 322 | 323 | 1. **Disable destructive operations**: 324 | ```python 325 | "ALLOW_KEY_DELETE": False 326 | ``` 327 | 328 | 2. **Use read-only mode for sensitive data**: 329 | ```python 330 | "ALLOW_KEY_EDIT": False 331 | ``` 332 | 333 | 3. **Use SSL/TLS connections**: 334 | ```python 335 | "url": "rediss://user:password@host:6380/0" 336 | ``` 337 | 338 | 4. **Restrict admin access**: Ensure only trusted staff users have admin access 339 | 340 | 5. **Use environment variables** for sensitive data like passwords 341 | 342 | ### Network Security 343 | 344 | - Use Redis AUTH when possible 345 | - Restrict Redis server access to trusted networks 346 | - Use SSL/TLS for connections over untrusted networks 347 | - Consider using Redis ACLs for fine-grained access control 348 | 349 | ## Next Steps 350 | 351 | - [Quick Start Guide](quick-start.md) - Get started with your configured instances 352 | - [Features Overview](features.md) - Learn about all available features 353 | - [Development Setup](development.md) - Set up for local development 354 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development Setup 2 | 3 | This guide will help you set up Django Redis Panel for local development and contribution. 4 | 5 | ## Prerequisites 6 | 7 | Before setting up the development environment, make sure you have: 8 | 9 | - **Python 3.9+**: The minimum supported Python version 10 | - **Redis Server**: A running Redis instance for testing 11 | - **Git**: For version control 12 | - **Make**: For using the included Makefile commands 13 | 14 | ### System Dependencies 15 | 16 | === "macOS" 17 | ```bash 18 | # Install Python (if not already installed) 19 | brew install python@3.11 20 | 21 | # Install Redis 22 | brew install redis 23 | 24 | # Start Redis 25 | brew services start redis 26 | ``` 27 | 28 | === "Ubuntu/Debian" 29 | ```bash 30 | # Install Python and pip 31 | sudo apt update 32 | sudo apt install python3 python3-pip python3-venv 33 | 34 | # Install Redis 35 | sudo apt install redis-server 36 | 37 | # Start Redis 38 | sudo systemctl start redis-server 39 | sudo systemctl enable redis-server 40 | ``` 41 | 42 | === "CentOS/RHEL" 43 | ```bash 44 | # Install Python 45 | sudo dnf install python3 python3-pip 46 | 47 | # Install Redis 48 | sudo dnf install redis 49 | 50 | # Start Redis 51 | sudo systemctl start redis 52 | sudo systemctl enable redis 53 | ``` 54 | 55 | === "Docker" 56 | ```bash 57 | # Use the included docker-compose file 58 | docker-compose up redis -d 59 | ``` 60 | 61 | ## Getting the Source Code 62 | 63 | ### 1. Fork and Clone 64 | 65 | 1. **Fork the repository** on GitHub: [yassi/dj-redis-panel](https://github.com/yassi/dj-redis-panel) 66 | 67 | 2. **Clone your fork**: 68 | ```bash 69 | git clone https://github.com/YOUR_USERNAME/dj-redis-panel.git 70 | cd dj-redis-panel 71 | ``` 72 | 73 | 3. **Add upstream remote**: 74 | ```bash 75 | git remote add upstream https://github.com/yassi/dj-redis-panel.git 76 | ``` 77 | 78 | ### 2. Create Virtual Environment 79 | 80 | ```bash 81 | # Create virtual environment 82 | python -m venv venv 83 | 84 | # Activate virtual environment 85 | source venv/bin/activate # On Windows: venv\Scripts\activate 86 | ``` 87 | 88 | ## Development Installation 89 | 90 | The project includes a Makefile with several useful commands for development. 91 | 92 | ### Quick Setup 93 | 94 | ```bash 95 | # Install in development mode with all dependencies 96 | make install 97 | ``` 98 | 99 | This command will: 100 | - Build and install the package in development mode 101 | - Install all development dependencies (pytest, coverage, etc.) 102 | - Set up the package for local development 103 | 104 | ### Manual Setup 105 | 106 | If you prefer manual installation: 107 | 108 | ```bash 109 | # Install development dependencies 110 | pip install -e ".[dev]" 111 | 112 | # Or install from requirements 113 | pip install -r requirements.txt 114 | ``` 115 | 116 | ### Available Make Commands 117 | 118 | ```bash 119 | # Build and install the package 120 | make install 121 | 122 | # Run all tests 123 | make test 124 | 125 | # Run tests with coverage 126 | make test_coverage 127 | 128 | # Clean build artifacts 129 | make clean 130 | 131 | # Build distribution packages 132 | make build 133 | 134 | # Upload to PyPI (maintainers only) 135 | make publish 136 | 137 | # Serve documentation locally 138 | make docs_serve 139 | ``` 140 | 141 | ## Project Structure 142 | 143 | Understanding the project layout: 144 | 145 | ``` 146 | dj-redis-panel/ 147 | ├── dj_redis_panel/ # Main package 148 | │ ├── __init__.py 149 | │ ├── admin.py # Django admin integration 150 | │ ├── apps.py # Django app configuration 151 | │ ├── models.py # Django models (empty) 152 | │ ├── redis_utils.py # Redis utility functions 153 | │ ├── urls.py # URL patterns 154 | │ ├── views.py # Django views 155 | │ └── templates/ # Django templates 156 | │ └── admin/ 157 | │ └── dj_redis_panel/ 158 | │ ├── base.html 159 | │ ├── index.html 160 | │ ├── instance_overview.html 161 | │ ├── key_detail.html 162 | │ ├── key_search.html 163 | │ └── styles.css 164 | ├── example_project/ # Example Django project 165 | │ ├── manage.py 166 | │ └── example_project/ 167 | │ ├── __init__.py 168 | │ ├── settings.py # Django settings 169 | │ ├── urls.py # URL configuration 170 | │ ├── wsgi.py 171 | │ └── management/ # Custom management commands 172 | │ └── commands/ 173 | │ └── populate_redis.py 174 | ├── tests/ # Test suite 175 | │ ├── __init__.py 176 | │ ├── base.py # Test base classes 177 | │ ├── conftest.py # Pytest configuration 178 | │ ├── test_index.py # Index view tests 179 | │ ├── test_instance_overview.py 180 | │ ├── test_key_detail.py 181 | │ └── test_key_search.py 182 | ├── docs/ # Documentation 183 | ├── images/ # Screenshots for README 184 | ├── pyproject.toml # Project configuration 185 | ├── requirements.txt # Development dependencies 186 | ├── Makefile # Development commands 187 | └── README.md # Project documentation 188 | ``` 189 | 190 | ## Example Project Setup 191 | 192 | The repository includes an example Django project for development and testing. 193 | 194 | ### 1. Set Up the Example Project 195 | 196 | ```bash 197 | cd example_project 198 | 199 | # Run migrations (creates Django tables, not Redis Panel tables) 200 | python manage.py migrate 201 | 202 | # Create a superuser 203 | python manage.py createsuperuser 204 | ``` 205 | 206 | ### 2. Populate Test Data 207 | 208 | ```bash 209 | # Populate Redis with sample data for testing 210 | python manage.py populate_redis 211 | ``` 212 | 213 | This command creates various types of Redis keys for testing: 214 | - String keys with different formats (JSON, plain text) 215 | - Hash keys with user profiles 216 | - List keys with notifications 217 | - Set keys with tags and permissions 218 | - Sorted set keys with leaderboards 219 | 220 | ### 3. Run the Development Server 221 | 222 | ```bash 223 | python manage.py runserver 224 | ``` 225 | 226 | Visit `http://127.0.0.1:8000/admin/` to access the Django admin with Redis Panel. 227 | 228 | 229 | ## Documentation Development 230 | 231 | ### Building Documentation 232 | 233 | The documentation is built with MkDocs: 234 | 235 | ```bash 236 | # Install documentation dependencies 237 | pip install mkdocs mkdocs-material mkdocstrings[python] 238 | 239 | # Serve documentation locally 240 | mkdocs serve 241 | 242 | # Build documentation 243 | mkdocs build 244 | ``` 245 | 246 | ### Writing Documentation 247 | 248 | Documentation is written in Markdown and located in the `docs/` directory: 249 | 250 | - Follow the existing structure and style 251 | - Include code examples for new features 252 | - Add screenshots for UI changes 253 | - Update the navigation in `mkdocs.yml` 254 | 255 | 256 | ## Getting Help 257 | 258 | ### Development Questions 259 | 260 | - **GitHub Discussions**: [Project discussions](https://github.com/yassi/dj-redis-panel/discussions) 261 | - **Issues**: [Report bugs or request features](https://github.com/yassi/dj-redis-panel/issues) 262 | - **Email**: Contact maintainers directly for sensitive issues 263 | 264 | ### Resources 265 | 266 | - **Django Documentation**: [Django Project](https://docs.djangoproject.com/) 267 | - **Redis Documentation**: [Redis Commands](https://redis.io/commands) 268 | - **Python Packaging**: [PyPA Guides](https://packaging.python.org/) 269 | - **Testing**: [Pytest Documentation](https://docs.pytest.org/) 270 | 271 | Thank you for contributing to Django Redis Panel! 🎉 272 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Screenshots 2 | 3 | This page showcases the Django Redis Panel interface with detailed screenshots of all major features. 4 | 5 | ## Django Admin Integration 6 | 7 | Django Redis Panel integrates seamlessly into your existing Django admin interface. A new section for Redis management appears alongside your regular Django models. 8 | 9 | !!! note "No Models Required" 10 | This application doesn't introduce any Django models or require database migrations. It's purely a Redis management interface. 11 | 12 | ![Admin Home](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/admin_home.png) 13 | 14 | **Features shown:** 15 | - Clean integration with Django admin styling 16 | - Redis Panel section in the admin home 17 | - "Manage Redis keys and values" entry point 18 | 19 | ## Instance List 20 | 21 | The main landing page shows all configured Redis instances with their connection status and basic information. 22 | 23 | ![Instance List](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/instances_list.png) 24 | 25 | **Features shown:** 26 | - Multiple Redis instance support 27 | - Connection status indicators 28 | - Instance descriptions and connection details 29 | - Quick access to instance management 30 | 31 | ## Instance Overview 32 | 33 | Each Redis instance has a detailed overview page showing server information, database statistics, and quick navigation options. 34 | 35 | ![Instance Overview](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/instance_overview.png) 36 | 37 | **Features shown:** 38 | - Server information (version, uptime, memory usage) 39 | - Database statistics with key counts 40 | - Memory usage per database 41 | - Quick links to browse keys in specific databases 42 | - Real-time connection status 43 | 44 | ## Key Search - Page-Based Pagination 45 | 46 | The key search interface supports traditional page-based navigation, perfect for smaller datasets and when you need predictable page jumping. 47 | 48 | ![Key Search - Page Index](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_search_page_index.png) 49 | 50 | **Features shown:** 51 | - Search pattern input with examples 52 | - Database selection dropdown 53 | - Traditional pagination with page numbers 54 | - Key type indicators 55 | - Results per page selection 56 | - Total key count display 57 | 58 | ## Key Search - Cursor-Based Pagination 59 | 60 | For larger datasets, cursor-based pagination provides better performance and stability during data changes. 61 | 62 | ![Key Search - Cursor](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_search_cursor.png) 63 | 64 | **Features shown:** 65 | - Efficient cursor-based navigation 66 | - Next/Previous controls 67 | - Stable pagination during key modifications 68 | - Better performance with large key sets 69 | - Same search and filtering capabilities 70 | 71 | ## Key Detail - String Values 72 | 73 | String keys are displayed with syntax highlighting and full editing capabilities. 74 | 75 | ![Key Detail - String](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_detail_string.png) 76 | 77 | **Features shown:** 78 | - Key information panel (name, type, TTL, size) 79 | - Formatted value display with syntax highlighting 80 | - Edit functionality with large text area 81 | - TTL management controls 82 | - Delete confirmation 83 | - JSON/XML automatic formatting 84 | 85 | ## Key Detail - Complex Data Structures 86 | 87 | Complex Redis data types like hashes are displayed in an organized, tabular format for easy browsing. 88 | 89 | ![Key Detail - Hash](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_detail_hash.png) 90 | 91 | **Features shown:** 92 | - Hash fields displayed in a clean table format 93 | - Field names and values clearly separated 94 | - Pagination for large hashes 95 | - Key metadata (type, field count, memory usage) 96 | - Consistent interface design 97 | 98 | 99 | ## Getting the Most from the Interface 100 | 101 | ### Pro Tips 102 | 103 | 1. **Use Specific Patterns**: Instead of `*`, use patterns like `user:*` for better performance 104 | 2. **Database Selection**: Use the database dropdown to narrow your search scope 105 | 3. **Cursor Pagination**: Enable for large datasets in your configuration 106 | 4. **Keyboard Shortcuts**: Learn the tab navigation for faster operation 107 | 108 | ### Best Practices 109 | 110 | 1. **Test in Development**: Always test destructive operations in a safe environment 111 | 2. **Use Read-Only Mode**: Configure read-only access for production viewing 112 | 3. **Monitor Performance**: Watch Redis performance when browsing large datasets 113 | 4. **Regular Backups**: Ensure proper Redis backup procedures before making changes 114 | 115 | The screenshots above represent the current version of Django Redis Panel. The interface continues to evolve with new features and improvements in each release. 116 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django Redis Panel 2 | 3 | A Django Admin panel for browsing, inspecting, and managing Redis keys. No PostgreSQL/MySQL models or changes required. 4 | 5 | ![Django Redis Panel - Instance List](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_search_page_index.png) 6 | 7 | ## Overview 8 | 9 | Django Redis Panel seamlessly integrates into your existing Django admin interface, providing a powerful tool for Redis database management without requiring any model definitions or database migrations. 10 | 11 | ## Key Features 12 | 13 | - **Browse Redis Keys**: Search and filter Redis keys with pattern matching 14 | - **Instance Overview**: Monitor Redis instance metrics and database statistics 15 | - **Key Management**: View, edit, and delete Redis keys with support for all data types 16 | - **Feature Toggles**: Granular control over operations (delete, edit, TTL updates) 17 | - **Pagination**: Both traditional page-based and cursor-based pagination support 18 | - **Django Admin Integration**: Seamless integration with Django admin styling and dark mode 19 | - **Permission Control**: Respects Django admin permissions and staff-only access 20 | - **Multiple Instances**: Support for multiple Redis instances with different configurations 21 | 22 | ## Supported Redis Data Types 23 | 24 | - **String**: View and edit string values 25 | - **List**: Browse list items with pagination and edit/delete list items 26 | - **Set**: View set members and delete 27 | - **Hash**: Display hash fields and values in a table format. Ability to edit hash values and delete hash entries. 28 | - **Sorted Set**: Show sorted set members with scores. Able to delete set members and edit scores. 29 | 30 | 31 | ## Requirements 32 | 33 | - Python 3.9+ 34 | - Django 4.2+ 35 | - Redis 4.0+ 36 | - redis-py 4.0+ 37 | 38 | ## License 39 | 40 | This project is licensed under the MIT License. 41 | 42 | ## Getting Help 43 | 44 | - 📖 [Read the full documentation](installation.md) 45 | - 🐛 [Report issues on GitHub](https://github.com/yassi/dj-redis-panel/issues) 46 | - 💡 [Request features](https://github.com/yassi/dj-redis-panel/issues/new) 47 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | This guide will walk you through installing and setting up Django Redis Panel in your Django project. 4 | 5 | ## Prerequisites 6 | 7 | Before installing Django Redis Panel, make sure you have: 8 | 9 | - Python 3.9 or higher 10 | - Django 4.2 or higher 11 | - A running Redis server 12 | - redis-py 4.0 or higher 13 | 14 | ## Installation Steps 15 | 16 | ### 1. Install the Package 17 | 18 | Install Django Redis Panel using pip: 19 | 20 | ```bash 21 | pip install dj-redis-panel 22 | ``` 23 | 24 | ### 2. Add to Django Settings 25 | 26 | Add `dj_redis_panel` to your `INSTALLED_APPS` in your Django settings file: 27 | 28 | ```python 29 | # settings.py 30 | INSTALLED_APPS = [ 31 | 'django.contrib.admin', 32 | 'django.contrib.auth', 33 | 'django.contrib.contenttypes', 34 | 'django.contrib.sessions', 35 | 'django.contrib.messages', 36 | 'django.contrib.staticfiles', 37 | 'dj_redis_panel', # Add this line 38 | # ... your other apps 39 | ] 40 | ``` 41 | 42 | !!! note 43 | Django Redis Panel doesn't require any database migrations as it doesn't define any Django models. 44 | 45 | ### 3. Configure Redis Instances 46 | 47 | Add your Redis configuration to your Django settings: 48 | 49 | === "Single Instance" 50 | 51 | ```python 52 | # settings.py 53 | DJ_REDIS_PANEL_SETTINGS = { 54 | "INSTANCES": { 55 | "default": { 56 | "description": "Default Redis Instance", 57 | "host": "127.0.0.1", 58 | "port": 6379, 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | === "Multiple Instances" 65 | 66 | ```python 67 | # settings.py 68 | DJ_REDIS_PANEL_SETTINGS = { 69 | "INSTANCES": { 70 | "default": { 71 | "description": "Default Redis Instance", 72 | "host": "127.0.0.1", 73 | "port": 6379, 74 | }, 75 | "cache": { 76 | "description": "Cache Redis Instance", 77 | "host": "127.0.0.1", 78 | "port": 6379, 79 | }, 80 | "sessions": { 81 | "description": "Session Store", 82 | "url": "redis://127.0.0.1:6379", 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | === "With Authentication" 89 | 90 | ```python 91 | # settings.py 92 | DJ_REDIS_PANEL_SETTINGS = { 93 | "INSTANCES": { 94 | "secure": { 95 | "description": "Secure Redis Instance", 96 | "host": "127.0.0.1", 97 | "port": 6379, 98 | "password": "your-redis-password", 99 | }, 100 | "ssl_instance": { 101 | "description": "SSL Redis Instance", 102 | "url": "rediss://user:password@host:6380", 103 | } 104 | } 105 | } 106 | ``` 107 | 108 | ### 4. Include URLs 109 | 110 | Add the Django Redis Panel URLs to your main `urls.py` file: 111 | 112 | ```python 113 | # urls.py 114 | from django.contrib import admin 115 | from django.urls import path, include 116 | 117 | urlpatterns = [ 118 | path('admin/redis/', include('dj_redis_panel.urls')), # Add this line 119 | path('admin/', admin.site.urls), 120 | ] 121 | ``` 122 | 123 | !!! tip 124 | You can change the URL path from `admin/redis/` to any path you prefer, such as `redis/` or `db/redis/`. 125 | 126 | ### 5. Create Admin User (if needed) 127 | 128 | If you don't already have a Django admin superuser, create one: 129 | 130 | ```bash 131 | python manage.py createsuperuser 132 | ``` 133 | 134 | ### 6. Start the Development Server 135 | 136 | Start your Django development server: 137 | 138 | ```bash 139 | python manage.py runserver 140 | ``` 141 | 142 | ### 7. Access the Panel 143 | 144 | 1. Navigate to the Django admin at `http://127.0.0.1:8000/admin/` 145 | 2. Log in with your admin credentials 146 | 3. Look for the **"DJ_REDIS_PANEL"** section in the admin interface 147 | 4. Click **"Manage Redis keys and values"** to start browsing your Redis instances 148 | 149 | ## Verification 150 | 151 | To verify that everything is working correctly: 152 | 153 | 1. Check that you can see the Redis Panel section in your Django admin 154 | 2. Click on "Manage Redis keys and values" 155 | 3. You should see a list of your configured Redis instances 156 | 4. Click on an instance to view its overview and browse keys 157 | 158 | ## Troubleshooting 159 | 160 | ### Common Issues 161 | 162 | **Redis connection errors** 163 | : Make sure your Redis server is running and accessible at the configured host and port. 164 | 165 | **Permission denied** 166 | : Ensure you're logged in as a staff user with admin access. 167 | 168 | **Module not found** 169 | : Make sure `dj_redis_panel` is properly installed and added to `INSTALLED_APPS`. 170 | 171 | **URLs not found** 172 | : Verify that you've included the Redis Panel URLs in your main `urls.py` file. 173 | 174 | ### Getting Help 175 | 176 | If you encounter any issues during installation: 177 | 178 | - Check the [Configuration](configuration.md) guide for detailed settings 179 | - Review the [Quick Start](quick-start.md) guide 180 | - [Open an issue on GitHub](https://github.com/yassi/dj-redis-panel/issues) 181 | 182 | ## Next Steps 183 | 184 | Now that you have Django Redis Panel installed, learn how to: 185 | 186 | - [Configure advanced settings](configuration.md) 187 | - [Follow the quick start guide](quick-start.md) 188 | - [Explore all features](features.md) 189 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will get you up and running with Django Redis Panel in just a few minutes. 4 | 5 | ## Prerequisites 6 | 7 | Before starting, make sure you have: 8 | 9 | - ✅ Django Redis Panel [installed](installation.md) 10 | - ✅ A running Redis server 11 | - ✅ Django admin access 12 | 13 | ## Step 1: Access the Admin Panel 14 | 15 | 1. Start your Django development server: 16 | ```bash 17 | python manage.py runserver 18 | ``` 19 | 20 | 2. Navigate to your Django admin interface: 21 | ``` 22 | http://127.0.0.1:8000/admin/ 23 | ``` 24 | 25 | 3. Log in with your admin credentials 26 | 27 | 4. Look for the **"DJ_REDIS_PANEL"** section in the admin home page 28 | 29 | ## Step 2: Explore Your Redis Instances 30 | 31 | 1. Click on **"Manage Redis keys and values"** 32 | 33 | 2. You'll see a list of your configured Redis instances with their status: 34 | - 🟢 **Connected**: Instance is accessible 35 | - 🔴 **Error**: Connection failed 36 | 37 | 3. Click on any instance to view its overview 38 | 39 | ## Step 3: Instance Overview 40 | 41 | The instance overview page shows: 42 | 43 | - **Connection Info**: Host, port, database number 44 | - **Server Info**: Redis version, uptime, memory usage 45 | - **Database Stats**: Total keys, expires, memory usage per database 46 | - **Quick Actions**: Direct links to browse keys 47 | 48 | ![Instance Overview](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/instance_overview.png) 49 | 50 | ## Step 4: Browse Redis Keys 51 | 52 | 1. From the instance overview, click **"Browse Keys"** or navigate to the key search page 53 | 54 | 2. You'll see the key browser with: 55 | - **Search box**: Enter patterns like `user:*` or `session:*` 56 | - **Database selector**: Switch between Redis databases 57 | - **Pagination controls**: Navigate through results 58 | 59 | ![Key Search](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_search_page_index.png) 60 | 61 | ### Search Examples 62 | 63 | Try these search patterns: 64 | 65 | - `*` - Show all keys 66 | - `user:*` - All keys starting with "user:" 67 | - `*session*` - All keys containing "session" 68 | - `cache:user:*` - Nested pattern matching 69 | 70 | ## Step 5: Inspect Key Details 71 | 72 | 1. Click on any key from the search results 73 | 74 | 2. The key detail page shows: 75 | - **Key information**: Name, type, TTL, size 76 | - **Value display**: Formatted based on data type 77 | - **Actions**: Edit, delete, update TTL (if enabled) 78 | 79 | ![Key Detail - String](https://raw.githubusercontent.com/yassi/dj-redis-panel/main/images/key_detail_string.png) 80 | 81 | ### Data Type Examples 82 | 83 | === "String" 84 | Simple text values with edit capability 85 | ``` 86 | Key: user:1:name 87 | Value: "John Doe" 88 | ``` 89 | 90 | === "Hash" 91 | Field-value pairs displayed in a table 92 | ``` 93 | Key: user:1:profile 94 | Fields: 95 | - name: "John Doe" 96 | - email: "john@example.com" 97 | - age: "30" 98 | ``` 99 | 100 | === "List" 101 | Ordered list of values with pagination 102 | ``` 103 | Key: user:1:notifications 104 | Items: 105 | 0: "Welcome message" 106 | 1: "New feature available" 107 | ``` 108 | 109 | === "Set" 110 | Unique values in no particular order 111 | ``` 112 | Key: user:1:tags 113 | Members: 114 | - "premium" 115 | - "verified" 116 | - "active" 117 | ``` 118 | 119 | ## Step 6: Key Management (Optional) 120 | 121 | If key editing is enabled in your configuration, you can: 122 | 123 | ### Edit Key Values 124 | 125 | 1. Click the **"Edit"** button on a key detail page 126 | 2. Modify the value in the text area 127 | 3. Click **"Save"** to update the key 128 | 129 | ### Update TTL (Time To Live) 130 | 131 | 1. Use the TTL controls to set expiration 132 | 2. Options include: 133 | - Set specific expiration time 134 | - Set seconds/minutes/hours from now 135 | - Remove expiration (make key persistent) 136 | 137 | ### Delete Keys 138 | 139 | !!! warning "Destructive Operation" 140 | Key deletion is permanent and cannot be undone. Use with caution. 141 | 142 | 1. Click the **"Delete"** button 143 | 2. Confirm the deletion in the popup 144 | 3. The key will be permanently removed from Redis 145 | 146 | ## Common Workflows 147 | 148 | ### Debugging Application Issues 149 | 150 | 1. **Search for user-specific keys**: 151 | ``` 152 | user:123:* 153 | ``` 154 | 155 | 2. **Check session data**: 156 | ``` 157 | session:* 158 | ``` 159 | 160 | 3. **Inspect cache entries**: 161 | ``` 162 | cache:* 163 | ``` 164 | 165 | ### Cache Management 166 | 167 | 1. **Find all cache keys**: 168 | ``` 169 | cache:* 170 | ``` 171 | 172 | 2. **Check cache hit rates** in instance overview 173 | 174 | 3. **Clear specific cache entries** by deleting keys 175 | 176 | ### Session Debugging 177 | 178 | 1. **Find user sessions**: 179 | ``` 180 | session:* 181 | ``` 182 | 183 | 2. **Inspect session data** to debug login issues 184 | 185 | 3. **Remove problematic sessions** if needed 186 | 187 | ## Tips and Best Practices 188 | 189 | ### Search Efficiency 190 | 191 | - Use specific patterns instead of `*` for large datasets 192 | - Enable cursor-based pagination for better performance 193 | - Use database selection to narrow down results 194 | 195 | ### Safety 196 | 197 | - Always verify key contents before deletion 198 | - Use read-only mode in production environments 199 | - Test configuration changes in development first 200 | 201 | ### Performance 202 | 203 | - Enable cursor pagination for large key sets 204 | - Use specific search patterns to reduce result sets 205 | - Monitor Redis memory usage in instance overview 206 | 207 | ## Troubleshooting 208 | 209 | ### Can't see Redis Panel in admin 210 | 211 | - Verify `dj_redis_panel` is in `INSTALLED_APPS` 212 | - Check that you're logged in as a staff user 213 | - Ensure URLs are properly configured 214 | 215 | ### Connection errors 216 | 217 | - Verify Redis server is running 218 | - Check host, port, and credentials in configuration 219 | - Test Redis connection outside of Django 220 | 221 | ### No keys visible 222 | 223 | - Verify you're looking in the correct database 224 | - Check if Redis instance actually contains data 225 | - Try using `*` pattern to show all keys 226 | 227 | ## Next Steps 228 | 229 | Now that you're familiar with the basics: 230 | 231 | - [Explore all features](features.md) in detail 232 | - [Learn about configuration options](configuration.md) 233 | - [View screenshots](screenshots.md) of all interfaces 234 | - [Understand Redis data types](redis-data-types.md) support 235 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Django Redis Panel includes a comprehensive test suite to ensure reliability and prevent regressions. This guide covers running tests, writing new tests, and understanding the testing infrastructure. 4 | 5 | ## Test Overview 6 | 7 | ### Test Structure 8 | 9 | The test suite is organized in the `tests/` directory: 10 | 11 | ``` 12 | tests/ 13 | ├── __init__.py 14 | ├── base.py # Base test classes 15 | ├── conftest.py # Pytest configuration 16 | ├── test_index.py # Index view tests 17 | ├── test_instance_overview.py # Instance overview tests 18 | ├── test_key_detail.py # Key detail view tests 19 | └── test_key_search.py # Key search tests 20 | ``` 21 | 22 | ### Test Types 23 | 24 | - **View Tests**: Most test are created as functional tests that are directly 25 | calling views. 26 | 27 | ## Running Tests 28 | 29 | ### Prerequisites 30 | 31 | Before running tests, ensure you have: 32 | 33 | - **Redis server** running on `127.0.0.1:6379` - consider running `docker compose up redis -d` 34 | - **Test databases** 12-15 available 35 | - **Development dependencies** run `make install` 36 | 37 | ### Quick Test Commands 38 | 39 | ```bash 40 | # Run all tests 41 | make test 42 | 43 | # Run with coverage report 44 | make test_coverage 45 | 46 | # Run specific test file 47 | pytest tests/test_views.py 48 | 49 | # Run tests in parallel 50 | pytest -n auto 51 | ``` 52 | 53 | ### Detailed Test Commands 54 | 55 | ```bash 56 | # Run tests with specific markers 57 | pytest -m "not slow" 58 | 59 | # Run tests matching pattern 60 | pytest -k "test_key_detail" 61 | 62 | # Run tests with debugging 63 | pytest --pdb 64 | 65 | # Run tests with coverage and HTML report 66 | pytest --cov=dj_redis_panel --cov-report=html 67 | 68 | # Run tests with timing information 69 | pytest --durations=10 70 | ``` 71 | 72 | ## Test Configuration 73 | 74 | ### Pytest Configuration 75 | 76 | Tests are configured in `pytest.ini`: 77 | 78 | ```ini 79 | [tool:pytest] 80 | DJANGO_SETTINGS_MODULE = example_project.settings 81 | testpaths = tests 82 | addopts = --tb=short --strict-markers 83 | markers = 84 | slow: marks tests as slow (deselect with '-m "not slow"') 85 | ``` 86 | 87 | ### Django Test Settings 88 | 89 | The example project includes test-specific settings that are useful for manual testing 90 | 91 | ```python 92 | # example_project/settings.py 93 | DATABASES = { 94 | 'default': { 95 | 'ENGINE': 'django.db.backends.sqlite3', 96 | 'NAME': ':memory:', # In-memory database for tests 97 | } 98 | } 99 | 100 | # Redis test configuration 101 | DJ_REDIS_PANEL_SETTINGS = { 102 | "INSTANCES": { 103 | "test": { 104 | "description": "Test Redis Instance", 105 | "host": "127.0.0.1", 106 | "port": 6379, 107 | "db": 13, # Use database 13 for tests 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | ### Test Database Setup 114 | 115 | Tests use Redis databases 12, 13, 14, and 15 to avoid interfering with development data: 116 | 117 | - **Database 12**: Reserved for large collection testing (e.g. pagination related tests) 118 | - **Database 13**: Primary test database 119 | - **Database 14**: Secondary test database for multi-instance tests 120 | - **Database 15**: Reserved for special test cases 121 | 122 | ### Manual Testing 123 | For manually testing dj-redis-panel, a cli utiliy has been created in order to easily 124 | create sample data in a redis instance. For safety reasons, this utility is part 125 | of the example project within the repo and not directly part of the dj-redis-panel 126 | package. 127 | 128 | ```bash 129 | # run from the example project directory 130 | python manage.py populate redis 131 | ``` 132 | -------------------------------------------------------------------------------- /example_project/example_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/example_project/example_project/__init__.py -------------------------------------------------------------------------------- /example_project/example_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example_project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example_project/example_project/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/example_project/example_project/management/__init__.py -------------------------------------------------------------------------------- /example_project/example_project/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/example_project/example_project/management/commands/__init__.py -------------------------------------------------------------------------------- /example_project/example_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.23. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | # This is just an example project, it is fine to leave this here. 24 | SECRET_KEY = "django-insecure-&9%3k12n*0aub%5=fk29cnrw1=oy0@l6-=fu4b4_n=&^8yd5vq" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "dj_redis_panel", 42 | "example_project", # For management commands 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | ROOT_URLCONF = "example_project.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = "example_project.wsgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.sqlite3", 82 | "NAME": BASE_DIR / "db.sqlite3", 83 | } 84 | } 85 | 86 | 87 | # Password validation 88 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 89 | 90 | AUTH_PASSWORD_VALIDATORS = [ 91 | { 92 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 102 | }, 103 | ] 104 | 105 | 106 | # Internationalization 107 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 108 | 109 | LANGUAGE_CODE = "en-us" 110 | 111 | TIME_ZONE = "UTC" 112 | 113 | USE_I18N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 120 | 121 | STATIC_URL = "static/" 122 | 123 | # Default primary key field type 124 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 125 | 126 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 127 | 128 | 129 | # Django Redis Panel Configuration 130 | DJ_REDIS_PANEL_SETTINGS = { 131 | "ALLOW_KEY_DELETE": False, # Example of global feature 132 | "ALLOW_KEY_EDIT": False, 133 | "ALLOW_TTL_UPDATE": False, 134 | "CURSOR_PAGINATED_SCAN": False, 135 | "CURSOR_PAGINATED_COLLECTIONS": False, 136 | # Global timeout settings (in seconds) 137 | # These will be used as defaults for all instances unless overridden 138 | "socket_timeout": 5.0, # Time to wait for socket operations 139 | "socket_connect_timeout": 5.0, # Time to wait for connection establishment 140 | "INSTANCES": { 141 | "local_redis": { 142 | "description": "Local Redis Instance", 143 | "host": "127.0.0.1", 144 | "port": 6379, 145 | "features": { # Instance-specific features, default to globalif not found 146 | "ALLOW_KEY_DELETE": True, 147 | "ALLOW_KEY_EDIT": True, 148 | "ALLOW_TTL_UPDATE": True, 149 | "CURSOR_PAGINATED_SCAN": True, 150 | "CURSOR_PAGINATED_COLLECTIONS": True, 151 | }, 152 | }, 153 | "local_redis_from_url_no_features": { 154 | "description": "Local Redis Instance from URL", 155 | "url": "redis://127.0.0.1:6379", 156 | }, 157 | "Unreachable instance": { 158 | "description": "this instance should fail to connect", 159 | "url": "redis://127.1.1.1:6379", 160 | "socket_connect_timeout": 0.1, 161 | "socket_timeout": 0.1, 162 | }, 163 | }, 164 | } 165 | 166 | 167 | # Simple Console Logging Configuration 168 | LOGGING = { 169 | "version": 1, 170 | "disable_existing_loggers": False, 171 | "formatters": { 172 | "simple": { 173 | "format": "{asctime} [{levelname}] {name}: {message}", 174 | "style": "{", 175 | }, 176 | }, 177 | "handlers": { 178 | "console": { 179 | "class": "logging.StreamHandler", 180 | "formatter": "simple", 181 | }, 182 | }, 183 | "loggers": { 184 | # Django Redis Panel logging 185 | "dj_redis_panel": { 186 | "handlers": ["console"], 187 | "level": "DEBUG" if DEBUG else "INFO", 188 | "propagate": True, 189 | }, 190 | # Root logger 191 | "root": { 192 | "handlers": ["console"], 193 | "level": "WARNING", 194 | }, 195 | }, 196 | } 197 | -------------------------------------------------------------------------------- /example_project/example_project/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for example_project project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path, include 20 | 21 | urlpatterns = [ 22 | path("admin/redis/", include("dj_redis_panel.urls")), 23 | path("admin/", admin.site.urls), 24 | ] 25 | -------------------------------------------------------------------------------- /example_project/example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /images/admin_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/admin_home.png -------------------------------------------------------------------------------- /images/instance_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/instance_overview.png -------------------------------------------------------------------------------- /images/instances_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/instances_list.png -------------------------------------------------------------------------------- /images/key_detail_hash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/key_detail_hash.png -------------------------------------------------------------------------------- /images/key_detail_string.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/key_detail_string.png -------------------------------------------------------------------------------- /images/key_search_cursor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/key_search_cursor.png -------------------------------------------------------------------------------- /images/key_search_page_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/images/key_search_page_index.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django Redis Panel 2 | site_description: A Django Admin panel for browsing, inspecting, and managing Redis keys 3 | site_author: Yasser Toruno 4 | site_url: https://yassi.github.io/dj-redis-panel/ 5 | 6 | repo_name: yassi/dj-redis-panel 7 | repo_url: https://github.com/yassi/dj-redis-panel 8 | 9 | theme: 10 | name: material 11 | features: 12 | - navigation.tabs.sticky 13 | - navigation.sections 14 | - navigation.expand 15 | - navigation.path 16 | - navigation.top 17 | - search.highlight 18 | - search.share 19 | - content.code.copy 20 | - content.code.annotate 21 | palette: 22 | # Palette toggle for light mode 23 | - scheme: default 24 | primary: green 25 | accent: green 26 | toggle: 27 | icon: material/brightness-7 28 | name: Switch to dark mode 29 | # Palette toggle for dark mode 30 | - scheme: slate 31 | primary: green 32 | accent: green 33 | toggle: 34 | icon: material/brightness-4 35 | name: Switch to light mode 36 | icon: 37 | repo: fontawesome/brands/github 38 | 39 | nav: 40 | - Home: index.md 41 | - Getting Started: 42 | - Installation: installation.md 43 | - Configuration: configuration.md 44 | - Quick Start: quick-start.md 45 | - Features: 46 | - Features: features.md 47 | - Development: 48 | - Development Setup: development.md 49 | - Testing: testing.md 50 | 51 | markdown_extensions: 52 | - pymdownx.highlight: 53 | anchor_linenums: true 54 | line_spans: __span 55 | pygments_lang_class: true 56 | - pymdownx.inlinehilite 57 | - pymdownx.snippets 58 | - pymdownx.superfences 59 | - admonition 60 | - pymdownx.details 61 | - pymdownx.tabbed: 62 | alternate_style: true 63 | - attr_list 64 | - md_in_html 65 | - pymdownx.emoji: 66 | emoji_index: !!python/name:pymdownx.emoji.twemoji 67 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 68 | 69 | plugins: 70 | - search 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dj-redis-panel" 7 | version = "0.5.0" 8 | description = "A Django Admin panel for browsing and inspecting Redis keys" 9 | readme = "README.md" 10 | license = {text = "MIT"} 11 | authors = [ 12 | {name = "Yasser Toruno"}, 13 | ] 14 | maintainers = [ 15 | {name = "Yasser Toruno"}, 16 | ] 17 | requires-python = ">=3.9" 18 | classifiers = [ 19 | "Framework :: Django", 20 | "Framework :: Django :: 4.2", 21 | "Framework :: Django :: 5.0", 22 | "Framework :: Django :: 5.1", 23 | "Framework :: Django :: 5.2", 24 | "License :: OSI Approved :: MIT License", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Operating System :: OS Independent", 30 | "Intended Audience :: Developers", 31 | "Topic :: Software Development :: Libraries :: Application Frameworks", 32 | ] 33 | keywords = ["django", "redis", "admin", "panel", "database", "key-value"] 34 | dependencies = [ 35 | "Django>=4.2", 36 | "redis>=4.0", 37 | ] 38 | 39 | [project.optional-dependencies] 40 | dev = [ 41 | "pytest>=7.0.0", 42 | "pytest-django>=4.5.0", 43 | "pytest-cov>=4.0.0", 44 | "pytest-xdist>=3.2.0", 45 | ] 46 | build = [ 47 | "build>=1.0.0", 48 | "twine>=4.0.0", 49 | ] 50 | 51 | [project.urls] 52 | Homepage = "https://yassi.github.io/dj-redis-panel/" 53 | Documentation = "https://yassi.github.io/dj-redis-panel/" 54 | Repository = "https://github.com/yassi/dj-redis-panel" 55 | "Bug Tracker" = "https://github.com/yassi/dj-redis-panel/issues" 56 | 57 | [tool.setuptools] 58 | include-package-data = true 59 | 60 | [tool.setuptools.packages.find] 61 | exclude = ["tests*", "example_project*"] 62 | 63 | [tool.setuptools.package-data] 64 | "dj_redis_panel" = ["templates/**/*"] 65 | 66 | # pytest configuration 67 | [tool.pytest.ini_options] 68 | DJANGO_SETTINGS_MODULE = "example_project.settings" 69 | testpaths = ["tests"] 70 | addopts = [ 71 | "--verbose", 72 | "--tb=short", 73 | "--strict-markers", 74 | "--disable-warnings", 75 | ] 76 | markers = [ 77 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 78 | ] 79 | 80 | # Coverage configuration 81 | [tool.coverage.run] 82 | source = ["dj_redis_panel"] 83 | omit = [ 84 | "*/migrations/*", 85 | "*/tests/*", 86 | "*/venv/*", 87 | "*/env/*", 88 | "example_project/*", 89 | "*/__pycache__/*", 90 | "*/site-packages/*", 91 | ] 92 | branch = true 93 | 94 | [tool.coverage.report] 95 | exclude_lines = [ 96 | "pragma: no cover", 97 | "def __repr__", 98 | "raise AssertionError", 99 | "raise NotImplementedError", 100 | "if __name__ == .__main__.:", 101 | "pass", 102 | "\\.\\.\\.", 103 | ] 104 | show_missing = true 105 | skip_covered = false 106 | precision = 2 107 | 108 | [tool.coverage.html] 109 | directory = "htmlcov" 110 | 111 | [tool.coverage.xml] 112 | output = "coverage.xml" 113 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | DJANGO_SETTINGS_MODULE = example_project.settings 3 | python_files = tests/test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = 7 | --strict-markers 8 | --strict-config 9 | --verbose 10 | --tb=short 11 | --maxfail=5 12 | --durations=10 13 | --reuse-db 14 | --ds=example_project.settings 15 | pythonpath = example_project 16 | markers = 17 | slow: marks tests as slow (deselect with '-m "not slow"') 18 | integration: marks tests as integration tests 19 | unit: marks tests as unit tests 20 | redis: marks tests that require Redis connection 21 | testpaths = tests 22 | filterwarnings = 23 | ignore::django.utils.deprecation.RemovedInDjango50Warning 24 | ignore::DeprecationWarning 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # core dependencies 2 | Django>=4.2 3 | redis>=4.0 4 | 5 | # Testing dependencies for Django Redis Panel 6 | pytest>=7.0.0 7 | pytest-django>=4.5.0 8 | pytest-cov>=4.0.0 9 | pytest-xdist>=3.2.0 10 | 11 | # build dependencies 12 | twine==6.1.0 13 | build==1.3.0 14 | 15 | #documentation dependencies 16 | mkdocs-material==9.1.12 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yassi/dj-redis-panel/8394b6510694950337136779456b59a4ef16bf72/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base test class for Django Redis Panel tests. 3 | 4 | This module provides a base test class with common setup and teardown logic 5 | for Redis connections, Django settings mocking, and test data management. 6 | """ 7 | import redis 8 | from django.test import TestCase, Client 9 | from django.contrib.auth.models import User 10 | from unittest.mock import patch 11 | 12 | 13 | class RedisTestCase(TestCase): 14 | """ 15 | Base test class for Django Redis Panel tests. 16 | 17 | Provides common setup for: 18 | - Redis connectivity checking 19 | - Test user creation 20 | - Redis test data cleanup 21 | - Django settings mocking 22 | - Common test data setup 23 | """ 24 | 25 | @classmethod 26 | def setUpClass(cls): 27 | """Set up test class with Redis connection check.""" 28 | super().setUpClass() 29 | # Test Redis connectivity 30 | try: 31 | cls.redis_conn = redis.Redis(host='127.0.0.1', port=6379, db=15, decode_responses=True) 32 | cls.redis_conn.ping() 33 | except redis.ConnectionError: 34 | cls.redis_available = False 35 | else: 36 | cls.redis_available = True 37 | 38 | def setUp(self): 39 | """Set up test data before each test.""" 40 | if not self.redis_available: 41 | self.skipTest("Redis server not available for testing") 42 | 43 | # Create admin user 44 | self.admin_user = User.objects.create_user( 45 | username='admin', 46 | email='admin@example.com', 47 | password='testpass123', 48 | is_staff=True, 49 | is_superuser=True 50 | ) 51 | 52 | # Create authenticated client 53 | self.client = Client() 54 | self.client.login(username='admin', password='testpass123') 55 | 56 | # Clean test databases 57 | self.cleanup_test_databases() 58 | 59 | # Set up Redis test data 60 | self.setup_redis_test_data() 61 | 62 | # Set up Django settings mock 63 | self.setup_settings_mock() 64 | 65 | def tearDown(self): 66 | """Clean up after each test.""" 67 | # Stop the settings mock 68 | if hasattr(self, 'settings_patcher'): 69 | self.settings_patcher.stop() 70 | 71 | # Clean test databases 72 | if self.redis_available: 73 | self.cleanup_test_databases() 74 | 75 | def cleanup_test_databases(self): 76 | """Clean up test Redis databases.""" 77 | test_dbs = [12, 13, 14, 15] 78 | for db_num in test_dbs: 79 | test_conn = redis.Redis(host='127.0.0.1', port=6379, db=db_num, decode_responses=True) 80 | try: 81 | test_conn.flushdb() 82 | except redis.ConnectionError: 83 | pass # Ignore connection errors during cleanup 84 | 85 | def setup_redis_test_data(self): 86 | """ 87 | Set up basic Redis test data. 88 | 89 | Override this method in subclasses to add specific test data: 90 | 91 | Example: 92 | def setup_redis_test_data(self): 93 | super().setup_redis_test_data() # Get base data 94 | self.redis_conn.set('custom:key', 'custom_value') 95 | """ 96 | # Set up test data in Redis database 15 (main test database) 97 | self.redis_conn.select(15) 98 | 99 | # Basic string keys 100 | basic_data = { 101 | 'test:string': 'test_value', 102 | 'user:123': 'john_doe', 103 | 'user:456': 'jane_doe', 104 | 'cache:data': 'cached_content', 105 | 'session:abc123': 'session_data', 106 | 'temp:key': 'temporary_value', 107 | } 108 | 109 | for key, value in basic_data.items(): 110 | self.redis_conn.set(key, value) 111 | 112 | # Set TTL on some keys 113 | self.redis_conn.expire('session:abc123', 3600) 114 | self.redis_conn.expire('temp:key', 1800) 115 | 116 | # Create different data types 117 | self.redis_conn.lpush('test:list', 'item1', 'item2', 'item3') 118 | self.redis_conn.sadd('test:set', 'member1', 'member2', 'member3') 119 | self.redis_conn.hset('test:hash', mapping={ 120 | 'field1': 'value1', 121 | 'field2': 'value2', 122 | 'field3': 'value3' 123 | }) 124 | self.redis_conn.zadd('test:zset', { 125 | 'member1': 1.0, 126 | 'member2': 2.0, 127 | 'member3': 3.0 128 | }) 129 | 130 | # Add test data to other databases for multi-database testing 131 | self.setup_multi_database_test_data() 132 | 133 | def setup_multi_database_test_data(self): 134 | """Set up test data across multiple Redis databases.""" 135 | # Database 13 - URL-based connection testing 136 | conn_13 = redis.Redis(host='127.0.0.1', port=6379, db=13, decode_responses=True) 137 | conn_13.set('url_test:key1', 'value1') 138 | conn_13.set('url_test:key2', 'value2') 139 | 140 | # Database 14 - Feature-disabled testing 141 | conn_14 = redis.Redis(host='127.0.0.1', port=6379, db=14, decode_responses=True) 142 | conn_14.set('no_features:string', 'test_value') 143 | conn_14.set('no_features:counter', '42') 144 | conn_14.set('no_features:session', 'session_data') 145 | 146 | def setup_settings_mock(self): 147 | """Set up Django settings mock with test Redis configuration.""" 148 | self.redis_test_settings = self.get_test_settings() 149 | 150 | # Start the settings mock 151 | self.settings_patcher = patch('dj_redis_panel.redis_utils.RedisPanelUtils.get_settings') 152 | self.mock_get_settings = self.settings_patcher.start() 153 | self.mock_get_settings.return_value = self.redis_test_settings 154 | 155 | def get_test_settings(self): 156 | """ 157 | Get test Redis settings configuration. 158 | 159 | Override this method in subclasses to customize settings: 160 | 161 | Example: 162 | def get_test_settings(self): 163 | settings = super().get_test_settings() 164 | settings["ALLOW_KEY_DELETE"] = False 165 | return settings 166 | """ 167 | return { 168 | "ALLOW_KEY_DELETE": True, 169 | "ALLOW_KEY_EDIT": True, 170 | "ALLOW_TTL_UPDATE": True, 171 | "CURSOR_PAGINATED_SCAN": False, 172 | "INSTANCES": { 173 | "test_redis": { 174 | "description": "Test Redis Instance", 175 | "host": "127.0.0.1", 176 | "port": 6379, 177 | "db": 15, # Use test database 15 178 | "features": { 179 | "ALLOW_KEY_DELETE": True, 180 | "ALLOW_KEY_EDIT": True, 181 | "ALLOW_TTL_UPDATE": True, 182 | "CURSOR_PAGINATED_SCAN": False, 183 | }, 184 | }, 185 | "test_redis_no_features": { 186 | "description": "Test Redis Instance - No Features", 187 | "host": "127.0.0.1", 188 | "port": 6379, 189 | "db": 14, # Use test database 14 190 | "features": { 191 | "ALLOW_KEY_DELETE": False, 192 | "ALLOW_KEY_EDIT": False, 193 | "ALLOW_TTL_UPDATE": False, 194 | "CURSOR_PAGINATED_SCAN": True, 195 | }, 196 | }, 197 | "test_redis_url": { 198 | "description": "Test Redis from URL", 199 | "url": "redis://127.0.0.1:6379/13", # Use test database 13 200 | }, 201 | "test_redis_cursor": { 202 | "description": "Test Redis Instance - Cursor Pagination", 203 | "host": "127.0.0.1", 204 | "port": 6379, 205 | "db": 12, # Use test database 12 206 | "features": { 207 | "ALLOW_KEY_DELETE": True, 208 | "ALLOW_KEY_EDIT": True, 209 | "ALLOW_TTL_UPDATE": True, 210 | "CURSOR_PAGINATED_COLLECTIONS": True, 211 | }, 212 | }, 213 | } 214 | } 215 | 216 | def create_unauthenticated_client(self): 217 | """Create an unauthenticated Django test client.""" 218 | return Client() 219 | 220 | def add_test_key(self, key, value, db=15, ttl=None): 221 | """ 222 | Helper method to add a test key to Redis. 223 | 224 | Args: 225 | key: Redis key name 226 | value: Redis key value 227 | db: Database number (default: 15) 228 | ttl: Time to live in seconds (optional) 229 | """ 230 | conn = redis.Redis(host='127.0.0.1', port=6379, db=db, decode_responses=True) 231 | if ttl: 232 | conn.setex(key, ttl, value) 233 | else: 234 | conn.set(key, value) 235 | 236 | def delete_test_key(self, key, db=15): 237 | """ 238 | Helper method to delete a test key from Redis. 239 | 240 | Args: 241 | key: Redis key name 242 | db: Database number (default: 15) 243 | """ 244 | conn = redis.Redis(host='127.0.0.1', port=6379, db=db, decode_responses=True) 245 | conn.delete(key) 246 | 247 | def key_exists(self, key, db=15): 248 | """ 249 | Helper method to check if a key exists in Redis. 250 | 251 | Args: 252 | key: Redis key name 253 | db: Database number (default: 15) 254 | 255 | Returns: 256 | bool: True if key exists, False otherwise 257 | """ 258 | conn = redis.Redis(host='127.0.0.1', port=6379, db=db, decode_responses=True) 259 | return bool(conn.exists(key)) 260 | 261 | def get_key_value(self, key, db=15): 262 | """ 263 | Helper method to get a key value from Redis. 264 | 265 | Args: 266 | key: Redis key name 267 | db: Database number (default: 15) 268 | 269 | Returns: 270 | str: Key value or None if key doesn't exist 271 | """ 272 | conn = redis.Redis(host='127.0.0.1', port=6379, db=db, decode_responses=True) 273 | return conn.get(key) 274 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytest configuration for Django Redis Panel tests. 3 | 4 | This configuration enables pytest-django to work with Django TestCase classes. 5 | """ 6 | import os 7 | import sys 8 | import django 9 | from django.conf import settings 10 | 11 | def pytest_configure(config): 12 | """Configure Django for pytest.""" 13 | # Add the example_project directory to Python path for Django settings 14 | example_project_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'example_project') 15 | if example_project_path not in sys.path: 16 | sys.path.insert(0, example_project_path) 17 | 18 | # Set Django settings module 19 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example_project.settings') 20 | 21 | # Setup Django 22 | if not settings.configured: 23 | django.setup() 24 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Django Admin integration with Django Redis Panel. 3 | 4 | The Django Redis Panel integrates with Django Admin through a placeholder model 5 | that appears in the admin interface and redirects to the Redis Panel when clicked. 6 | """ 7 | import redis 8 | from django.contrib import admin 9 | from django.contrib.auth import get_user_model 10 | from django.test import TestCase, Client 11 | from django.urls import reverse 12 | 13 | from dj_redis_panel.admin import RedisPanelPlaceholderAdmin 14 | from dj_redis_panel.models import RedisPanelPlaceholder 15 | from .base import RedisTestCase 16 | 17 | 18 | User = get_user_model() 19 | 20 | 21 | class TestAdminIntegration(RedisTestCase): 22 | """Test cases for Django Admin integration.""" 23 | 24 | def test_redis_panel_appears_in_admin_index(self): 25 | """Test that the Redis Panel appears in the Django admin index page.""" 26 | response = self.client.get('/admin/') 27 | 28 | self.assertEqual(response.status_code, 200) 29 | # Check for the app name and model 30 | self.assertContains(response, 'dj_redis_panel') 31 | 32 | # Check that the link to the changelist exists 33 | changelist_url = reverse('admin:dj_redis_panel_redispanelplaceholder_changelist') 34 | self.assertContains(response, changelist_url) 35 | 36 | def test_redis_panel_changelist_redirects_to_index(self): 37 | """Test that clicking the Redis Panel in admin redirects to the Redis Panel index.""" 38 | changelist_url = reverse('admin:dj_redis_panel_redispanelplaceholder_changelist') 39 | response = self.client.get(changelist_url) 40 | 41 | # Should redirect to the Redis Panel index 42 | self.assertEqual(response.status_code, 302) 43 | expected_url = reverse('dj_redis_panel:index') 44 | self.assertRedirects(response, expected_url) 45 | 46 | def test_unauthenticated_user_cannot_access_admin_redis_panel(self): 47 | """Test that unauthenticated users cannot access the Redis Panel through admin.""" 48 | client = self.create_unauthenticated_client() 49 | changelist_url = reverse('admin:dj_redis_panel_redispanelplaceholder_changelist') 50 | response = client.get(changelist_url) 51 | 52 | # Should redirect to login page 53 | self.assertEqual(response.status_code, 302) 54 | self.assertIn('/admin/login/', response.url) 55 | 56 | def test_non_staff_user_cannot_access_admin_redis_panel(self): 57 | """Test that non-staff users cannot access the Redis Panel through admin.""" 58 | # Create a non-staff user 59 | user = User.objects.create_user( 60 | username='regular_user', 61 | password='testpass123', 62 | is_staff=False 63 | ) 64 | 65 | client = Client() 66 | client.force_login(user) 67 | 68 | changelist_url = reverse('admin:dj_redis_panel_redispanelplaceholder_changelist') 69 | response = client.get(changelist_url) 70 | 71 | # Should redirect to login page or show permission denied 72 | self.assertIn(response.status_code, [302, 403]) 73 | -------------------------------------------------------------------------------- /tests/test_index.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Django Redis Panel index view using Django TestCase. 3 | 4 | The index view displays a list of configured Redis instances with their 5 | status, connection information, and basic metrics. 6 | """ 7 | from django.urls import reverse 8 | from .base import RedisTestCase 9 | 10 | 11 | class TestIndexView(RedisTestCase): 12 | """Test cases for the main index view using Django TestCase.""" 13 | 14 | def test_index_requires_staff_permission(self): 15 | """Test that index view requires staff permission.""" 16 | # Use unauthenticated client 17 | client = self.create_unauthenticated_client() 18 | url = reverse('dj_redis_panel:index') 19 | response = client.get(url) 20 | 21 | # Should redirect to login page 22 | self.assertEqual(response.status_code, 302) 23 | self.assertIn('/admin/login/', response.url) 24 | 25 | def test_index_view_success(self): 26 | """Test successful index view rendering with real Redis.""" 27 | url = reverse('dj_redis_panel:index') 28 | response = self.client.get(url) 29 | 30 | # Check response 31 | self.assertEqual(response.status_code, 200) 32 | self.assertTemplateUsed(response, 'admin/dj_redis_panel/index.html') 33 | 34 | # Check context data 35 | self.assertIn('redis_instances', response.context) 36 | redis_instances = response.context['redis_instances'] 37 | 38 | # Should have instances from test settings 39 | self.assertEqual(len(redis_instances), 4) # test_redis, test_redis_no_features, test_redis_url, test_redis_cursor 40 | 41 | # Check instance structure 42 | for instance in redis_instances: 43 | self.assertIn('alias', instance) 44 | self.assertIn('config', instance) 45 | self.assertIn('status', instance) 46 | self.assertIn(instance['alias'], ['test_redis', 'test_redis_no_features', 'test_redis_url', 'test_redis_cursor']) 47 | 48 | # For connected instances, check they have real data 49 | if instance['status'] == 'connected': 50 | self.assertIn('hero_numbers', instance) 51 | self.assertIn('databases', instance) 52 | self.assertIsNotNone(instance['hero_numbers']) 53 | 54 | def test_index_view_with_connection_error(self): 55 | """Test index view when Redis connection fails.""" 56 | # Create a disconnected instance configuration 57 | disconnected_settings = { 58 | "INSTANCES": { 59 | "disconnected_redis": { 60 | "description": "Disconnected Redis Instance", 61 | "host": "127.0.0.1", 62 | "port": 9999, # Non-existent port 63 | "features": { 64 | "ALLOW_KEY_DELETE": True, 65 | "ALLOW_KEY_EDIT": True, 66 | "ALLOW_TTL_UPDATE": True, 67 | "CURSOR_PAGINATED_SCAN": False, 68 | }, 69 | } 70 | } 71 | } 72 | 73 | # Update the mock to return disconnected settings 74 | self.mock_get_settings.return_value = disconnected_settings 75 | 76 | url = reverse('dj_redis_panel:index') 77 | response = self.client.get(url) 78 | 79 | # Should still render successfully 80 | self.assertEqual(response.status_code, 200) 81 | 82 | # Check error handling 83 | redis_instances = response.context['redis_instances'] 84 | self.assertEqual(len(redis_instances), 1) 85 | instance = redis_instances[0] 86 | self.assertEqual(instance['status'], 'disconnected') 87 | self.assertIsNotNone(instance['error']) 88 | 89 | def test_index_view_context_structure(self): 90 | """Test that index view provides correct context structure.""" 91 | url = reverse('dj_redis_panel:index') 92 | response = self.client.get(url) 93 | 94 | # Check required context fields 95 | context = response.context 96 | required_fields = [ 97 | 'title', 'opts', 'has_permission', 'site_title', 98 | 'site_header', 'site_url', 'user', 'redis_instances' 99 | ] 100 | 101 | for field in required_fields: 102 | self.assertIn(field, context, f"Missing context field: {field}") 103 | 104 | # Check title 105 | self.assertEqual(context['title'], "DJ Redis Panel - Instances") 106 | 107 | # Check redis_instances structure 108 | redis_instances = context['redis_instances'] 109 | for instance in redis_instances: 110 | required_instance_fields = [ 111 | 'alias', 'config', 'status', 'info', 'error', 112 | 'total_keys', 'hero_numbers', 'databases' 113 | ] 114 | for field in required_instance_fields: 115 | self.assertIn(field, instance, f"Missing instance field: {field}") 116 | 117 | def test_index_view_multiple_instances(self): 118 | """Test index view with multiple Redis instances.""" 119 | url = reverse('dj_redis_panel:index') 120 | response = self.client.get(url) 121 | 122 | # Check all instances are present 123 | redis_instances = response.context['redis_instances'] 124 | self.assertEqual(len(redis_instances), 4) 125 | 126 | # Check instance aliases are correct 127 | instance_aliases = {inst['alias'] for inst in redis_instances} 128 | expected_aliases = {'test_redis', 'test_redis_no_features', 'test_redis_url', 'test_redis_cursor'} 129 | self.assertEqual(instance_aliases, expected_aliases) 130 | 131 | # Check that at least some instances are connected (those using test databases) 132 | connected_instances = [inst for inst in redis_instances if inst['status'] == 'connected'] 133 | self.assertGreaterEqual(len(connected_instances), 2) # test_redis and test_redis_no_features should connect 134 | 135 | def test_index_view_no_instances_configured(self): 136 | """Test index view when no Redis instances are configured.""" 137 | # Mock empty instances 138 | empty_settings = {"INSTANCES": {}} 139 | self.mock_get_settings.return_value = empty_settings 140 | 141 | url = reverse('dj_redis_panel:index') 142 | response = self.client.get(url) 143 | 144 | self.assertEqual(response.status_code, 200) 145 | self.assertEqual(response.context['redis_instances'], []) 146 | 147 | 148 | def test_index_view_database_information(self): 149 | """Test that database information is properly populated.""" 150 | url = reverse('dj_redis_panel:index') 151 | response = self.client.get(url) 152 | 153 | redis_instances = response.context['redis_instances'] 154 | 155 | for instance in redis_instances: 156 | if instance['status'] == 'connected': 157 | databases = instance['databases'] 158 | 159 | # Should have at least one database with keys 160 | self.assertGreater(len(databases), 0) 161 | 162 | # Check database structure 163 | for db in databases: 164 | required_db_fields = ['db_number', 'keys', 'is_default'] 165 | for field in required_db_fields: 166 | self.assertIn(field, db) 167 | 168 | # Check that db_number is an integer 169 | self.assertIsInstance(db['db_number'], int) 170 | 171 | # Check that keys count is an integer 172 | self.assertIsInstance(db['keys'], int) 173 | 174 | # Check that is_default is boolean 175 | self.assertIsInstance(db['is_default'], bool) 176 | 177 | # Database 0 should be marked as default if present 178 | db_zero = next((db for db in databases if db['db_number'] == 0), None) 179 | if db_zero: 180 | self.assertTrue(db_zero['is_default']) 181 | -------------------------------------------------------------------------------- /tests/test_instance_overview.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Django Redis Panel instance overview view using Django TestCase. 3 | 4 | The instance overview view displays detailed information about a specific Redis 5 | instance including connection status, database information, and key metrics. 6 | """ 7 | import redis 8 | from django.urls import reverse 9 | from .base import RedisTestCase 10 | 11 | 12 | class TestInstanceOverviewView(RedisTestCase): 13 | """Test cases for the instance overview view using Django TestCase.""" 14 | 15 | def get_test_settings(self): 16 | """Get test settings with additional instance for multi-database testing.""" 17 | settings = super().get_test_settings() 18 | # Add additional instance for instance overview testing 19 | settings["INSTANCES"]["test_redis_multi_db"] = { 20 | "description": "Test Redis Instance - Multiple DBs", 21 | "host": "127.0.0.1", 22 | "port": 6379, 23 | "db": 14, # Use test database 14 24 | "features": { 25 | "ALLOW_KEY_DELETE": False, 26 | "ALLOW_KEY_EDIT": False, 27 | "ALLOW_TTL_UPDATE": False, 28 | "CURSOR_PAGINATED_SCAN": True, 29 | }, 30 | } 31 | return settings 32 | 33 | def setup_redis_test_data(self): 34 | """Set up test data specific to instance overview tests.""" 35 | # Call parent to get base test data 36 | super().setup_redis_test_data() 37 | 38 | # Add instance-overview specific test data 39 | self.redis_conn.select(15) 40 | 41 | # Additional keys for instance overview testing 42 | overview_data = { 43 | 'overview:string': 'string_value', 44 | 'overview:user:123': 'john_doe', 45 | 'overview:user:456': 'jane_doe', 46 | 'overview:cache': 'cached_content', 47 | 'overview:temp': 'temporary_value', 48 | } 49 | 50 | for key, value in overview_data.items(): 51 | self.redis_conn.set(key, value) 52 | 53 | # Add key with TTL and additional data types 54 | self.redis_conn.setex('overview:temp_ttl', 3600, 'temp_with_ttl') 55 | self.redis_conn.lpush('overview:list', 'item1', 'item2', 'item3') 56 | self.redis_conn.sadd('overview:set', 'member1', 'member2') 57 | self.redis_conn.hset('overview:hash', mapping={'field1': 'value1', 'field2': 'value2'}) 58 | self.redis_conn.zadd('overview:zset', {'member1': 1.0, 'member2': 2.0}) 59 | 60 | # Add specific data to database 14 for multi-database testing 61 | conn_14 = redis.Redis(host='127.0.0.1', port=6379, db=14, decode_responses=True) 62 | multi_db_data = { 63 | 'multi_db:string': 'test_value', 64 | 'multi_db:counter': '42', 65 | 'multi_db:session': 'session_data', 66 | } 67 | for key, value in multi_db_data.items(): 68 | conn_14.set(key, value) 69 | 70 | def test_instance_overview_requires_staff_permission(self): 71 | """Test that instance overview requires staff permission.""" 72 | # Use unauthenticated client 73 | client = self.create_unauthenticated_client() 74 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 75 | response = client.get(url) 76 | 77 | # Should redirect to login page 78 | self.assertEqual(response.status_code, 302) 79 | self.assertIn('/admin/login/', response.url) 80 | 81 | def test_instance_overview_success(self): 82 | """Test successful instance overview rendering with real Redis data.""" 83 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 84 | response = self.client.get(url) 85 | 86 | # Check response 87 | self.assertEqual(response.status_code, 200) 88 | self.assertTemplateUsed(response, 'admin/dj_redis_panel/instance_overview.html') 89 | 90 | # Check context data 91 | context = response.context 92 | self.assertEqual(context['title'], "Instance Overview: test_redis") 93 | self.assertEqual(context['instance_alias'], 'test_redis') 94 | self.assertIn('instance_config', context) 95 | self.assertIn('hero_numbers', context) 96 | self.assertIn('databases', context) 97 | self.assertIsNone(context['error_message']) 98 | 99 | # Check hero numbers are populated 100 | hero_numbers = context['hero_numbers'] 101 | self.assertIn('version', hero_numbers) 102 | self.assertIn('memory_used', hero_numbers) 103 | self.assertIn('connected_clients', hero_numbers) 104 | self.assertIn('uptime', hero_numbers) 105 | 106 | # Check databases information 107 | databases = context['databases'] 108 | self.assertGreater(len(databases), 0) 109 | 110 | # Should find database 15 with our test keys 111 | db15_found = False 112 | for db in databases: 113 | if db['db_number'] == 15: 114 | db15_found = True 115 | self.assertGreater(db['keys'], 0) # Should have our test keys 116 | break 117 | self.assertTrue(db15_found, "Database 15 should be present in databases list") 118 | 119 | def test_instance_overview_nonexistent_instance(self): 120 | """Test instance overview with nonexistent instance raises 404.""" 121 | url = reverse('dj_redis_panel:instance_overview', args=['nonexistent_instance']) 122 | response = self.client.get(url) 123 | self.assertEqual(response.status_code, 404) 124 | 125 | def test_instance_overview_connection_error(self): 126 | """Test instance overview when Redis connection fails.""" 127 | # Create a disconnected instance configuration 128 | disconnected_settings = { 129 | "INSTANCES": { 130 | "disconnected_redis": { 131 | "description": "Disconnected Redis Instance", 132 | "host": "127.0.0.1", 133 | "port": 9999, # Non-existent port 134 | "features": { 135 | "ALLOW_KEY_DELETE": True, 136 | "ALLOW_KEY_EDIT": True, 137 | "ALLOW_TTL_UPDATE": True, 138 | "CURSOR_PAGINATED_SCAN": False, 139 | }, 140 | } 141 | } 142 | } 143 | 144 | # Update the mock to return disconnected settings 145 | self.mock_get_settings.return_value = disconnected_settings 146 | 147 | url = reverse('dj_redis_panel:instance_overview', args=['disconnected_redis']) 148 | response = self.client.get(url) 149 | 150 | # Should still render successfully but show error 151 | self.assertEqual(response.status_code, 200) 152 | self.assertIsNotNone(response.context['error_message']) 153 | # When connection fails, hero_numbers and databases are None, not empty dict/list 154 | self.assertIsNone(response.context['hero_numbers']) 155 | self.assertEqual(response.context['databases'], []) 156 | 157 | def test_instance_overview_context_structure(self): 158 | """Test that instance overview provides correct context structure.""" 159 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 160 | response = self.client.get(url) 161 | 162 | # Check required context fields 163 | context = response.context 164 | required_fields = [ 165 | 'title', 'opts', 'has_permission', 'site_title', 166 | 'site_header', 'site_url', 'user', 'instance_alias', 167 | 'instance_config', 'hero_numbers', 'databases', 'error_message' 168 | ] 169 | 170 | for field in required_fields: 171 | self.assertIn(field, context, f"Missing context field: {field}") 172 | 173 | # Check title format 174 | self.assertEqual(context['title'], "Instance Overview: test_redis") 175 | 176 | # Check instance config structure 177 | instance_config = context['instance_config'] 178 | self.assertIn('description', instance_config) 179 | self.assertEqual(instance_config['description'], "Test Redis Instance") 180 | 181 | def test_instance_overview_hero_numbers_structure(self): 182 | """Test that hero numbers contain expected fields and types.""" 183 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 184 | response = self.client.get(url) 185 | 186 | hero_numbers = response.context['hero_numbers'] 187 | 188 | # Check that hero numbers contain expected fields 189 | expected_fields = [ 190 | 'version', 'memory_used', 'memory_peak', 191 | 'connected_clients', 'uptime', 'total_commands_processed' 192 | ] 193 | 194 | for field in expected_fields: 195 | self.assertIn(field, hero_numbers, f"Missing hero number field: {field}") 196 | 197 | # Check data types 198 | self.assertIsInstance(hero_numbers['version'], str) 199 | self.assertIsInstance(hero_numbers['connected_clients'], int) 200 | self.assertIsInstance(hero_numbers['uptime'], int) 201 | self.assertIsInstance(hero_numbers['total_commands_processed'], int) 202 | 203 | # Check that numeric values are reasonable 204 | self.assertGreaterEqual(hero_numbers['connected_clients'], 0) 205 | self.assertGreaterEqual(hero_numbers['uptime'], 0) 206 | self.assertGreaterEqual(hero_numbers['total_commands_processed'], 0) 207 | 208 | def test_instance_overview_databases_structure(self): 209 | """Test that databases information has correct structure.""" 210 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 211 | response = self.client.get(url) 212 | 213 | databases = response.context['databases'] 214 | self.assertGreater(len(databases), 0) 215 | 216 | # Check database structure 217 | for db in databases: 218 | required_db_fields = ['db_number', 'keys', 'is_default'] 219 | for field in required_db_fields: 220 | self.assertIn(field, db, f"Missing database field: {field}") 221 | 222 | # Check data types 223 | self.assertIsInstance(db['db_number'], int) 224 | self.assertIsInstance(db['keys'], int) 225 | self.assertIsInstance(db['is_default'], bool) 226 | 227 | # Check ranges 228 | self.assertGreaterEqual(db['db_number'], 0) 229 | self.assertLessEqual(db['db_number'], 15) # Redis default max DBs 230 | self.assertGreaterEqual(db['keys'], 0) 231 | 232 | # Database 0 should be marked as default if present 233 | db_zero = next((db for db in databases if db['db_number'] == 0), None) 234 | if db_zero: 235 | self.assertTrue(db_zero['is_default']) 236 | 237 | # Non-zero databases should not be default 238 | non_zero_dbs = [db for db in databases if db['db_number'] != 0] 239 | for db in non_zero_dbs: 240 | self.assertFalse(db['is_default']) 241 | 242 | def test_instance_overview_multiple_databases(self): 243 | """Test instance overview with multiple databases containing data.""" 244 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 245 | response = self.client.get(url) 246 | 247 | databases = response.context['databases'] 248 | 249 | # Should find our test databases with keys 250 | db_numbers_with_keys = [db['db_number'] for db in databases if db['keys'] > 0] 251 | 252 | # Should include databases 13, 14, 15 since we added test data 253 | expected_dbs = {13, 14, 15} 254 | found_dbs = set(db_numbers_with_keys) 255 | 256 | # Check that we found at least some of our test databases 257 | self.assertTrue(expected_dbs.intersection(found_dbs), 258 | f"Expected to find databases {expected_dbs}, but found {found_dbs}") 259 | 260 | def test_instance_overview_url_based_instance(self): 261 | """Test instance overview with URL-based Redis configuration.""" 262 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis_url']) 263 | response = self.client.get(url) 264 | 265 | # Should work with URL-based configuration 266 | self.assertEqual(response.status_code, 200) 267 | self.assertEqual(response.context['instance_alias'], 'test_redis_url') 268 | 269 | # Should have hero numbers and database info 270 | self.assertIsNotNone(response.context['hero_numbers']) 271 | self.assertGreater(len(response.context['databases']), 0) 272 | 273 | def test_instance_overview_database_key_counts(self): 274 | """Test that database key counts are accurate.""" 275 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 276 | response = self.client.get(url) 277 | 278 | databases = response.context['databases'] 279 | 280 | # Find database 15 (our main test database) 281 | db15 = next((db for db in databases if db['db_number'] == 15), None) 282 | self.assertIsNotNone(db15, "Database 15 should be present") 283 | 284 | # Should have multiple keys from our test data 285 | # We created: overview:string, overview:user:123, overview:user:456, 286 | # overview:cache, overview:temp, overview:temp_ttl, overview:list, 287 | # overview:set, overview:hash, overview:zset = 10 keys 288 | self.assertGreaterEqual(db15['keys'], 10) 289 | 290 | def test_instance_overview_empty_database(self): 291 | """Test instance overview with database that has no keys.""" 292 | # Clean database 15 completely 293 | self.redis_conn.select(15) 294 | self.redis_conn.flushdb() 295 | 296 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 297 | response = self.client.get(url) 298 | 299 | # Should still work, but database 15 might not appear in the list 300 | # (Redis only shows databases with keys or DB 0) 301 | self.assertEqual(response.status_code, 200) 302 | 303 | databases = response.context['databases'] 304 | db15 = next((db for db in databases if db['db_number'] == 15), None) 305 | 306 | if db15: 307 | # If DB 15 appears, it should have 0 keys 308 | self.assertEqual(db15['keys'], 0) 309 | 310 | def test_instance_overview_template_content(self): 311 | """Test that instance overview template contains expected content.""" 312 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 313 | response = self.client.get(url) 314 | 315 | # Check that important content is present 316 | self.assertContains(response, 'test_redis') 317 | # Check for generic Redis instance overview content 318 | self.assertContains(response, 'Instance Overview') 319 | # Check for hero numbers section content (these should be present) 320 | hero_numbers = response.context['hero_numbers'] 321 | if hero_numbers and 'version' in hero_numbers: 322 | self.assertContains(response, hero_numbers['version']) 323 | # Check for databases section 324 | self.assertContains(response, 'Database') 325 | 326 | # Should contain links to key search for databases with keys 327 | databases = response.context['databases'] 328 | for db in databases: 329 | if db['keys'] > 0: 330 | expected_search_url = reverse('dj_redis_panel:key_search', 331 | args=['test_redis', db['db_number']]) 332 | # The URL should appear in the response (as a link) 333 | self.assertContains(response, f'href="{expected_search_url}"') 334 | 335 | def test_instance_overview_different_instances(self): 336 | """Test instance overview with different instance configurations.""" 337 | test_instances = ['test_redis', 'test_redis_multi_db', 'test_redis_url'] 338 | 339 | for instance_alias in test_instances: 340 | with self.subTest(instance=instance_alias): 341 | url = reverse('dj_redis_panel:instance_overview', args=[instance_alias]) 342 | response = self.client.get(url) 343 | 344 | self.assertEqual(response.status_code, 200) 345 | self.assertEqual(response.context['instance_alias'], instance_alias) 346 | self.assertIn('hero_numbers', response.context) 347 | self.assertIn('databases', response.context) 348 | 349 | def test_instance_overview_redis_info_integration(self): 350 | """Test that Redis INFO command data is properly integrated.""" 351 | url = reverse('dj_redis_panel:instance_overview', args=['test_redis']) 352 | response = self.client.get(url) 353 | 354 | hero_numbers = response.context['hero_numbers'] 355 | 356 | # Version should be a valid Redis version string 357 | version = hero_numbers['version'] 358 | self.assertIsInstance(version, str) 359 | self.assertNotEqual(version, 'Unknown') 360 | 361 | # Memory values should be present and formatted 362 | memory_used = hero_numbers['memory_used'] 363 | self.assertIsInstance(memory_used, str) 364 | self.assertNotEqual(memory_used, 'Unknown') 365 | 366 | # Should contain typical Redis memory format (e.g., "1.23M", "456K") 367 | self.assertTrue(any(char.isdigit() for char in memory_used)) 368 | -------------------------------------------------------------------------------- /tests/test_key_search.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Django Redis Panel key search view using Django TestCase. 3 | 4 | The key search view provides paginated search functionality for Redis keys 5 | with support for both traditional page-based and cursor-based pagination. 6 | """ 7 | import redis 8 | from django.urls import reverse 9 | from dj_redis_panel.views import _get_page_range 10 | 11 | from .base import RedisTestCase 12 | 13 | 14 | class TestKeySearchView(RedisTestCase): 15 | """Test cases for the key search view using Django TestCase.""" 16 | 17 | def test_key_search_requires_staff_permission(self): 18 | """Test that key search requires staff permission.""" 19 | # Use unauthenticated client 20 | client = self.create_unauthenticated_client() 21 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 22 | response = client.get(url) 23 | 24 | # Should redirect to login page 25 | self.assertEqual(response.status_code, 302) 26 | self.assertIn('/admin/login/', response.url) 27 | 28 | def test_key_search_success(self): 29 | """Test successful key search rendering with real Redis data.""" 30 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 31 | response = self.client.get(url) 32 | 33 | # Check response 34 | self.assertEqual(response.status_code, 200) 35 | self.assertTemplateUsed(response, 'admin/dj_redis_panel/key_search.html') 36 | 37 | # Check context data 38 | context = response.context 39 | self.assertEqual(context['title'], "test_redis::DB15::Key Search") 40 | self.assertEqual(context['selected_db'], 15) 41 | self.assertEqual(context['search_query'], "*") 42 | self.assertGreaterEqual(context['total_keys'], 10) # Should find our test keys 43 | self.assertGreater(context['showing_keys'], 0) 44 | self.assertGreater(len(context['keys_data']), 0) 45 | self.assertIsNone(context['error_message']) 46 | self.assertFalse(context['use_cursor_pagination']) 47 | 48 | def test_key_search_nonexistent_instance(self): 49 | """Test key search with nonexistent instance raises 404.""" 50 | url = reverse('dj_redis_panel:key_search', args=['nonexistent', 15]) 51 | response = self.client.get(url) 52 | self.assertEqual(response.status_code, 404) 53 | 54 | def test_key_search_with_pattern(self): 55 | """Test key search with specific pattern.""" 56 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 57 | response = self.client.get(url, {'q': 'user:*'}) 58 | 59 | self.assertEqual(response.status_code, 200) 60 | self.assertEqual(response.context['search_query'], 'user:*') 61 | 62 | # Should find the user:123 and user:456 keys from our test data 63 | keys_data = response.context['keys_data'] 64 | user_keys = [key for key in keys_data if key['key'].startswith('user:')] 65 | self.assertGreaterEqual(len(user_keys), 2) 66 | 67 | def test_key_search_pagination_parameters(self): 68 | """Test key search with pagination parameters.""" 69 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 70 | response = self.client.get(url, { 71 | 'page': '1', 72 | 'per_page': '10' # Use 10 instead of 5, as 5 is not in the allowed values 73 | }) 74 | 75 | self.assertEqual(response.status_code, 200) 76 | self.assertEqual(response.context['per_page'], 10) 77 | self.assertEqual(response.context['current_page'], 1) 78 | 79 | # Should limit results to 10 per page 80 | self.assertLessEqual(len(response.context['keys_data']), 10) 81 | 82 | def test_key_search_invalid_pagination_parameters(self): 83 | """Test key search with invalid pagination parameters.""" 84 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 85 | response = self.client.get(url, { 86 | 'page': 'invalid', 87 | 'per_page': '999' # Not in allowed values 88 | }) 89 | 90 | self.assertEqual(response.status_code, 200) 91 | self.assertEqual(response.context['per_page'], 25) # Should default to 25 92 | self.assertEqual(response.context['current_page'], 1) # Should default to 1 93 | 94 | def test_key_search_cursor_pagination(self): 95 | """Test key search with cursor-based pagination enabled.""" 96 | # Set up data in database 14 for the cursor pagination instance 97 | conn_14 = redis.Redis(host='127.0.0.1', port=6379, db=14, decode_responses=True) 98 | conn_14.set('cursor_test:1', 'value1') 99 | conn_14.set('cursor_test:2', 'value2') 100 | 101 | try: 102 | url = reverse('dj_redis_panel:key_search', args=['test_redis_no_features', 14]) 103 | response = self.client.get(url, {'cursor': '0'}) 104 | 105 | self.assertEqual(response.status_code, 200) 106 | 107 | # Check cursor-specific context 108 | self.assertTrue(response.context['use_cursor_pagination']) 109 | self.assertIn('current_cursor', response.context) 110 | self.assertIn('next_cursor', response.context) 111 | finally: 112 | # Cleanup 113 | conn_14.flushdb() 114 | 115 | def test_key_search_success_message(self): 116 | """Test key search with success message (e.g., after key deletion).""" 117 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 118 | response = self.client.get(url, {'deleted': '1'}) 119 | 120 | self.assertEqual(response.status_code, 200) 121 | self.assertEqual(response.context['success_message'], "Key deleted successfully") 122 | self.assertIsNone(response.context['error_message']) 123 | 124 | def test_key_search_context_structure(self): 125 | """Test that key search provides correct context structure.""" 126 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 127 | response = self.client.get(url) 128 | 129 | # Check required context fields 130 | context = response.context 131 | required_fields = [ 132 | 'title', 'opts', 'has_permission', 'site_title', 133 | 'site_header', 'site_url', 'user', 'instance_alias', 134 | 'instance_config', 'search_query', 'selected_db', 135 | 'keys_data', 'total_keys', 'showing_keys', 'error_message', 136 | 'success_message', 'per_page', 'current_page', 'total_pages', 137 | 'has_previous', 'has_next', 'use_cursor_pagination' 138 | ] 139 | 140 | for field in required_fields: 141 | self.assertIn(field, context, f"Missing context field: {field}") 142 | 143 | def test_key_search_different_databases(self): 144 | """Test key search across different database numbers.""" 145 | # Add data to different databases 146 | for db_num in [13, 14]: 147 | conn = redis.Redis(host='127.0.0.1', port=6379, db=db_num, decode_responses=True) 148 | conn.set(f'db_{db_num}_key', f'db_{db_num}_value') 149 | 150 | try: 151 | # Test DB 15 (already has data) 152 | url_db15 = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 153 | response_db15 = self.client.get(url_db15) 154 | 155 | self.assertEqual(response_db15.status_code, 200) 156 | self.assertEqual(response_db15.context['selected_db'], 15) 157 | self.assertEqual(response_db15.context['title'], "test_redis::DB15::Key Search") 158 | 159 | # Test DB 14 (with cursor pagination enabled) 160 | url_db14 = reverse('dj_redis_panel:key_search', args=['test_redis_no_features', 14]) 161 | response_db14 = self.client.get(url_db14) 162 | 163 | self.assertEqual(response_db14.status_code, 200) 164 | self.assertEqual(response_db14.context['selected_db'], 14) 165 | self.assertEqual(response_db14.context['title'], "test_redis_no_features::DB14::Key Search") 166 | finally: 167 | # Cleanup 168 | for db_num in [13, 14]: 169 | conn = redis.Redis(host='127.0.0.1', port=6379, db=db_num, decode_responses=True) 170 | conn.flushdb() 171 | 172 | def test_key_search_per_page_options(self): 173 | """Test key search with different per_page options.""" 174 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 175 | valid_per_page_values = [10, 25, 50, 100] 176 | 177 | for per_page in valid_per_page_values: 178 | with self.subTest(per_page=per_page): 179 | response = self.client.get(url, {'per_page': str(per_page)}) 180 | self.assertEqual(response.status_code, 200) 181 | self.assertEqual(response.context['per_page'], per_page) 182 | 183 | def test_key_search_empty_results(self): 184 | """Test key search with no matching keys.""" 185 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 186 | response = self.client.get(url, {'q': 'nonexistent:*'}) 187 | 188 | self.assertEqual(response.status_code, 200) 189 | self.assertEqual(response.context['search_query'], 'nonexistent:*') 190 | 191 | # Should find no keys 192 | self.assertEqual(len(response.context['keys_data']), 0) 193 | self.assertEqual(response.context['total_keys'], 0) 194 | self.assertEqual(response.context['showing_keys'], 0) 195 | 196 | def test_key_search_key_types_displayed(self): 197 | """Test that different key types are properly displayed in search results.""" 198 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 199 | response = self.client.get(url, {'q': 'test:*'}) 200 | 201 | self.assertEqual(response.status_code, 200) 202 | 203 | keys_data = response.context['keys_data'] 204 | 205 | # Should find our test keys of different types 206 | key_types_found = {key['type'] for key in keys_data} 207 | expected_types = {'string', 'list', 'set', 'hash', 'zset'} 208 | 209 | # Should find at least some of these types 210 | self.assertTrue(key_types_found.intersection(expected_types)) 211 | 212 | # Check that each key has required fields 213 | for key_data in keys_data: 214 | required_fields = ['key', 'type', 'ttl', 'size'] 215 | for field in required_fields: 216 | self.assertIn(field, key_data) 217 | 218 | def test_key_search_pagination_navigation(self): 219 | """Test key search pagination navigation with many keys.""" 220 | # Add more keys to test pagination 221 | for i in range(50): 222 | self.redis_conn.set(f'pagination_test:{i}', f'value_{i}') 223 | 224 | try: 225 | url = reverse('dj_redis_panel:key_search', args=['test_redis', 15]) 226 | response = self.client.get(url, {'per_page': '10', 'q': 'pagination_test:*'}) 227 | 228 | self.assertEqual(response.status_code, 200) 229 | 230 | context = response.context 231 | self.assertEqual(context['per_page'], 10) 232 | self.assertGreaterEqual(context['total_keys'], 50) 233 | self.assertGreater(context['total_pages'], 1) 234 | 235 | # Should have pagination navigation 236 | self.assertIn('page_range', context) 237 | 238 | # Test second page 239 | response_page2 = self.client.get(url, { 240 | 'per_page': '10', 241 | 'q': 'pagination_test:*', 242 | 'page': '2' 243 | }) 244 | 245 | self.assertEqual(response_page2.status_code, 200) 246 | self.assertEqual(response_page2.context['current_page'], 2) 247 | self.assertTrue(response_page2.context['has_previous']) 248 | finally: 249 | # Cleanup pagination test keys 250 | for i in range(50): 251 | self.redis_conn.delete(f'pagination_test:{i}') 252 | 253 | 254 | class TestGetPageRange(RedisTestCase): 255 | """Test cases for the _get_page_range utility function.""" 256 | 257 | def setUp(self): 258 | """ 259 | Skip all the data setup for this test case 260 | """ 261 | pass 262 | 263 | def test_get_page_range_scenarios(self): 264 | """Test _get_page_range with comprehensive test cases.""" 265 | test_cases = [ 266 | # Small page counts (≤ 10) - should return all pages 267 | {"current": 1, "total": 1, "expected": [1], "description": "Single page"}, 268 | {"current": 1, "total": 2, "expected": [1, 2], "description": "Two pages"}, 269 | {"current": 2, "total": 2, "expected": [1, 2], "description": "Two pages, current=2"}, 270 | {"current": 1, "total": 5, "expected": [1, 2, 3, 4, 5], "description": "Five pages"}, 271 | {"current": 5, "total": 10, "expected": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "description": "Ten pages, current=5"}, 272 | {"current": 10, "total": 10, "expected": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "description": "Ten pages, current=10"}, 273 | 274 | # Edge case: 11 pages (first large case) 275 | {"current": 1, "total": 11, "expected": [1, 2, 3, 4, 5, 6, "...", 11], "description": "11 pages, current=1"}, 276 | {"current": 6, "total": 11, "expected": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], "description": "11 pages, current=6 (no ellipsis)"}, 277 | {"current": 11, "total": 11, "expected": [1, "...", 6, 7, 8, 9, 10, 11], "description": "11 pages, current=11"}, 278 | 279 | # Large page counts - current page at beginning 280 | {"current": 1, "total": 100, "expected": [1, 2, 3, 4, 5, 6, "...", 100], "description": "100 pages, current=1"}, 281 | {"current": 2, "total": 100, "expected": [1, 2, 3, 4, 5, 6, 7, "...", 100], "description": "100 pages, current=2"}, 282 | {"current": 3, "total": 100, "expected": [1, 2, 3, 4, 5, 6, 7, 8, "...", 100], "description": "100 pages, current=3"}, 283 | 284 | # Large page counts - current page at end 285 | {"current": 100, "total": 100, "expected": [1, "...", 95, 96, 97, 98, 99, 100], "description": "100 pages, current=100"}, 286 | {"current": 99, "total": 100, "expected": [1, "...", 94, 95, 96, 97, 98, 99, 100], "description": "100 pages, current=99"}, 287 | {"current": 98, "total": 100, "expected": [1, "...", 93, 94, 95, 96, 97, 98, 99, 100], "description": "100 pages, current=98"}, 288 | 289 | # Large page counts - current page in middle 290 | {"current": 50, "total": 100, "expected": [1, "...", 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, "...", 100], "description": "100 pages, current=50"}, 291 | {"current": 25, "total": 100, "expected": [1, "...", 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, "...", 100], "description": "100 pages, current=25"}, 292 | {"current": 75, "total": 100, "expected": [1, "...", 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, "...", 100], "description": "100 pages, current=75"}, 293 | 294 | # Range connection cases 295 | {"current": 4, "total": 20, "expected": [1, 2, 3, 4, 5, 6, 7, 8, 9, "...", 20], "description": "20 pages, current=4 (range includes first)"}, 296 | {"current": 17, "total": 20, "expected": [1, "...", 12, 13, 14, 15, 16, 17, 18, 19, 20], "description": "20 pages, current=17 (range includes last)"}, 297 | 298 | # Edge cases with invalid inputs 299 | {"current": 15, "total": 10, "expected": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "description": "current > total (graceful handling)"}, 300 | {"current": 0, "total": 10, "expected": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "description": "current = 0 (graceful handling)"}, 301 | {"current": 1, "total": 0, "expected": [], "description": "total = 0 (edge case)"}, 302 | ] 303 | 304 | for case in test_cases: 305 | with self.subTest(case=case["description"]): 306 | result = _get_page_range(case["current"], case["total"]) 307 | self.assertEqual( 308 | result, 309 | case["expected"], 310 | f"Failed for {case['description']}: " 311 | f"_get_page_range({case['current']}, {case['total']}) = {result}, " 312 | f"expected {case['expected']}" 313 | ) 314 | --------------------------------------------------------------------------------