├── .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 | [](https://github.com/yassi/dj-redis-panel/actions/workflows/test.yml)
4 | [](https://codecov.io/gh/yassi/dj-redis-panel)
5 | [](https://badge.fury.io/py/dj-redis-panel)
6 | [](https://pypi.org/project/dj-redis-panel/)
7 | [](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 | 
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 | 
69 |
70 | ### Instance Overview
71 | Monitor your Redis instances with detailed metrics and database information.
72 |
73 | 
74 |
75 | ### Key Search - Page-based Pagination
76 | Search for keys with traditional page-based navigation.
77 |
78 | 
79 |
80 | ### Key Search - Cursor-based Pagination
81 | Efficient cursor-based pagination for large datasets.
82 |
83 | 
84 |
85 | ### Key Detail - String Values
86 | View and edit string key values with TTL management.
87 |
88 | 
89 |
90 | ### Key Detail - Other data structures
91 | Browse keys with more complex data structures such as hashes, lists, etc.
92 |
93 | 
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 |
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 | {% trans 'Instance' %}
14 | {% trans 'Status' %}
15 | {% trans 'Version' %}
16 | {% trans 'Memory Used' %}
17 | {% trans 'Clients' %}
18 | {% trans 'Total Keys' %}
19 | {% trans 'Actions' %}
20 |
21 |
22 |
23 | {% for instance in redis_instances %}
24 |
25 |
26 | {{ instance.alias }}
27 | {% if instance.config.description %}
28 | {{ instance.config.description }}
29 | {% endif %}
30 |
31 |
32 | {% if instance.status == 'connected' %}
33 |
34 |
35 | {% trans 'Connected' %}
36 |
37 | {% else %}
38 |
39 |
40 | {% trans 'Disconnected' %}
41 |
42 | {% if instance.error %}
43 | {{ instance.error }}
44 | {% endif %}
45 | {% endif %}
46 |
47 |
48 | {% if instance.info %}
49 | {{ instance.info.redis_version }}
50 | {% else %}
51 | —
52 | {% endif %}
53 |
54 |
55 | {% if instance.info %}
56 | {{ instance.info.used_memory_human }}
57 | {% else %}
58 | —
59 | {% endif %}
60 |
61 |
62 | {% if instance.info %}
63 | {{ instance.info.connected_clients }}
64 | {% else %}
65 | —
66 | {% endif %}
67 |
68 |
69 | {% if instance.info %}
70 | {{ instance.total_keys }}
71 | {% else %}
72 | —
73 | {% endif %}
74 |
75 |
76 | {% if instance.status == 'connected' %}
77 |
78 | {% trans 'Browse Instance' %}
79 |
80 | {% else %}
81 | {% trans 'N/A' %}
82 | {% endif %}
83 |
84 |
85 | {% endfor %}
86 |
87 |
88 |
89 | {% else %}
90 |
91 |
92 | {% trans 'Redis Configuration Required' %}
93 |
94 |
99 |
100 |
133 |
134 |
135 |
136 |
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 | {% trans 'DB Name' %}
64 | {% trans 'Keys' %}
65 | {% trans 'Avg TTL (s)' %}
66 | {% trans 'Expires' %}
67 | {% trans 'Actions' %}
68 |
69 |
70 |
71 | {% for db in databases %}
72 |
73 |
74 | DB {{ db.db_number }}
75 | {% if db.is_default %}
76 | {% trans 'default' %}
77 | {% endif %}
78 |
79 |
80 | {% if db.keys > 0 %}
81 | {{ db.keys|floatformat:0 }}
82 | {% else %}
83 | {% trans 'Empty' %}
84 | {% endif %}
85 |
86 |
87 | {% if db.avg_ttl > 0 %}
88 | {{ db.avg_ttl|floatformat:0 }}s
89 | {% else %}
90 | —
91 | {% endif %}
92 |
93 |
94 | {% if db.expires > 0 %}
95 | {{ db.expires|floatformat:0 }}
96 | {% else %}
97 | —
98 | {% endif %}
99 |
100 |
101 | {% if db.keys > 0 %}
102 |
103 | {% trans 'Browse Keys' %}
104 |
105 | {% else %}
106 | {% trans 'No keys' %}
107 | {% endif %}
108 |
109 |
110 | {% endfor %}
111 |
112 |
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 |
37 | {% else %}
38 |
39 |
40 |
{% trans 'Redis Key Types:' %}
41 |
42 |
43 |
44 | {% trans 'Type' %}
45 | {% trans 'Description' %}
46 | {% trans 'Use Cases' %}
47 |
48 |
49 |
50 |
51 | {% trans 'String' %}
52 | {% trans 'Simple text or binary data value' %}
53 | {% trans 'Caching, counters, flags, JSON data' %}
54 |
55 |
56 | {% trans 'List' %}
57 | {% trans 'Ordered collection of strings' %}
58 | {% trans 'Queues, activity feeds, recent items' %}
59 |
60 |
61 | {% trans 'Set' %}
62 | {% trans 'Unordered collection of unique strings' %}
63 | {% trans 'Tags, unique visitors, permissions' %}
64 |
65 |
66 | {% trans 'Sorted Set' %}
67 | {% trans 'Ordered collection of unique strings with scores' %}
68 | {% trans 'Leaderboards, rankings, time-series data' %}
69 |
70 |
71 | {% trans 'Hash' %}
72 | {% trans 'Collection of field-value pairs' %}
73 | {% trans 'User profiles, settings, object storage' %}
74 |
75 |
76 |
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 |
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 |
39 | {% else %}
40 |
41 | {% endif %}
42 |
43 |
44 |
45 |
46 | {% trans 'Key Information' %}
47 |
48 |
56 |
57 |
65 |
66 |
78 |
79 |
91 |
92 |
93 |
94 |
95 | {% trans 'TTL Management' %}
96 |
97 | {% if allow_ttl_update %}
98 |
117 | {% else %}
118 |
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 |
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 |
14 |
15 | {% endif %}
16 |
17 | {% if key_data.type == "string" %}
18 |
19 |
24 |
25 | {% elif key_data.type == "list" %}
26 |
27 |
49 |
50 | {% elif key_data.type == "set" %}
51 |
52 |
66 |
67 | {% elif key_data.type == "zset" %}
68 |
69 |
88 |
89 | {% elif key_data.type == "hash" %}
90 |
91 |
110 |
111 | {% else %}
112 |
113 |
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 |
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 |
9 | {% else %}
10 |
13 | {% endif %}
14 | {% else %}
15 | {% if total_pages > 1 %}
16 |
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 | {% trans 'Field' %}
18 | {% trans 'Value' %}
19 | {% if allow_key_edit %}
20 | {% trans 'Actions' %}
21 | {% endif %}
22 |
23 |
24 |
25 | {% for field, value in key_data.value.items %}
26 |
27 | {{ field }}
28 |
29 | {% if allow_key_edit %}
30 |
39 | {% else %}
40 | {{ value }}
41 | {% endif %}
42 |
43 | {% if allow_key_edit %}
44 |
45 |
54 |
55 | {% endif %}
56 |
57 | {% endfor %}
58 |
59 |
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 | {% trans 'Index' %}
18 | {% trans 'Value' %}
19 | {% if allow_key_edit %}
20 | {% trans 'Actions' %}
21 | {% endif %}
22 |
23 |
24 |
25 | {% for item in key_data.value %}
26 |
27 | {% if is_paginated %}{{ start_index|add:forloop.counter0 }}{% else %}{{ forloop.counter0 }}{% endif %}
28 |
29 | {% if allow_key_edit %}
30 |
39 | {% else %}
40 | {{ item }}
41 | {% endif %}
42 |
43 | {% if allow_key_edit %}
44 |
45 |
54 |
55 | {% endif %}
56 |
57 | {% endfor %}
58 |
59 |
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 | {% trans 'Member' %}
18 | {% if allow_key_edit %}
19 | {% trans 'Delete' %}
20 | {% endif %}
21 |
22 |
23 |
24 | {% for member in key_data.value %}
25 |
26 | {{ member }}
27 | {% if allow_key_edit %}
28 |
29 |
38 |
39 | {% endif %}
40 |
41 | {% endfor %}
42 |
43 |
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 |
21 | {% else %}
22 |
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 |
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 |
30 |
31 |
32 |
33 |
34 | {% trans 'Add New Key' %}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {% if success_message %}
43 |
44 |
{{ success_message }}
45 |
46 | {% endif %}
47 |
48 | {% if error_message %}
49 |
50 |
{{ error_message }}
51 |
52 | {% endif %}
53 |
54 |
55 |
56 |
{% trans 'Search Redis Keys' %}
57 |
58 |
59 |
84 |
85 |
86 |
87 |
88 | {% if error_message %}
89 |
90 |
91 |
{% trans 'Error:' %} {{ error_message }}
92 |
93 |
94 | {% endif %}
95 |
96 |
97 | {% if not error_message %}
98 |
99 |
{% trans 'Search Results' %}
100 |
101 |
102 | {% if use_cursor_pagination %}
103 | {% if showing_keys > 0 %}
104 | {% blocktrans with count=showing_keys %}Showing {{ count }} keys{% endblocktrans %}
105 | {% if has_next %} - {% trans 'more available' %} {% endif %}
106 | {% if current_cursor > 0 %}{% trans 'Cursor position' %}: {{ current_cursor }} {% endif %}
107 | {% else %}
108 | {% trans 'No keys found matching your search pattern.' %}
109 | {% endif %}
110 | {% else %}
111 | {% if total_keys > 0 %}
112 | {% blocktrans with total=total_keys start=start_index end=end_index %}Found {{ total }} keys, showing {{ start }} to {{ end }}{% endblocktrans %}
113 | {% if total_keys >= 100000 %}
114 | {% trans 'Search limited to first 100,000 matching keys. Refine your search pattern for more specific results.' %}
115 | {% endif %}
116 | {% else %}
117 | {% trans 'No keys found matching your search pattern.' %}
118 | {% endif %}
119 | {% endif %}
120 |
121 |
122 | {% endif %}
123 |
124 |
125 | {% if keys_data and not error_message %}
126 |
127 |
128 |
129 |
130 | {% trans 'Key' %}
131 | {% trans 'Type' %}
132 | {% trans 'Size/Length' %}
133 | {% trans 'TTL' %}
134 | {% trans 'Actions' %}
135 |
136 |
137 |
138 | {% for key_info in keys_data %}
139 |
140 |
141 | {{ key_info.key }}
142 |
143 |
144 |
145 | {{ key_info.type|upper }}
146 |
147 |
148 |
149 | {% if key_info.type == 'string' %}
150 | {{ key_info.size }} {% trans 'bytes' %}
151 | {% else %}
152 | {{ key_info.size }} {% trans 'items' %}
153 | {% endif %}
154 |
155 |
156 | {% if key_info.ttl %}
157 | {{ key_info.ttl }}s
158 | {% else %}
159 | {% trans 'No expiry' %}
160 | {% endif %}
161 |
162 |
163 |
164 | {% trans 'View' %}
165 |
166 |
167 |
168 | {% endfor %}
169 |
170 |
171 |
172 |
173 |
174 | {% if use_cursor_pagination %}
175 |
176 | {% if current_cursor > 0 %}
177 | {% trans 'first' %}
178 | {% endif %}
179 |
180 | {% if has_next %}
181 | {% trans 'next' %}
182 | {% endif %}
183 |
184 | {% if total_keys > 0 %}
185 | {% trans 'Cursor-based pagination' %} - {{ showing_keys }} {% trans 'keys shown' %}
186 | {% if has_next %} ({% trans 'more available' %}){% endif %}
187 | {% endif %}
188 | {% else %}
189 |
190 | {% if total_pages > 1 %}
191 | {% if has_previous %}
192 | {% trans 'previous' %}
193 | {% endif %}
194 |
195 | {% for num in page_range %}
196 | {% if num == "..." %}
197 | …
198 | {% elif num == current_page %}
199 | {{ num }}
200 | {% else %}
201 | {{ num }}
202 | {% endif %}
203 | {% endfor %}
204 |
205 | {% if has_next %}
206 | {% trans 'next' %}
207 | {% endif %}
208 | {% endif %}
209 | {{ total_keys }} {% blocktrans count counter=total_keys %}key{% plural %}keys{% endblocktrans %}
210 | {% endif %}
211 |
212 |
213 | {% endif %}
214 |
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------