├── .flake8 ├── .github └── workflows │ └── publish-to-test-pypi.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── README.md ├── doc └── elastic_queries.png ├── elastic_panel ├── __init__.py ├── panel.py ├── static │ └── elastic_panel │ │ └── js │ │ └── elastic_panel.js └── templates │ └── elastic_panel │ └── elastic_panel.html ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── requirements.txt ├── settings.py └── test_toolbar.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = ANN,B,B9,BLK,C,D,DAR,E,F,I,S,W 3 | ignore = E203,E501,W503,D100,D415,D104,B008,B902 4 | max-line-length = 200 5 | max-complexity = 40 6 | application-import-names = tests 7 | import-order-style = google 8 | docstring-convention = google 9 | per-file-ignores = tests/*:S101,E402 10 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-test-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - django-version: "2.2.0" 13 | python-version: "3.8" 14 | - django-version: "3.2.0" 15 | python-version: "3.8" 16 | - django-version: "4.0.0" 17 | python-version: "3.8" 18 | - django-version: "2.2.0" 19 | python-version: "3.10" 20 | - django-version: "3.2.0" 21 | python-version: "3.10" 22 | - django-version: "4.0.0" 23 | python-version: "3.10" 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Upgrade pip version 32 | run: | 33 | python -m pip install -U pip 34 | 35 | - name: Install test requirements/linters 36 | run: pip install -r tests/requirements.txt 37 | 38 | - name: Upgrade django version 39 | run: | 40 | python -m pip install "Django~=${{ matrix.django-version }}" 41 | 42 | - name: Run pre-commit 43 | run: pre-commit run --all-files 44 | 45 | 46 | build-n-publish: 47 | needs: tests 48 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v2 52 | - name: Set up Python ${{ matrix.python-version }} 53 | uses: actions/setup-python@v2 54 | - name: Install pypa/build 55 | run: >- 56 | python -m 57 | pip install 58 | build 59 | --user 60 | - name: Build a binary wheel and a source tarball 61 | run: >- 62 | python -m 63 | build 64 | --sdist 65 | --wheel 66 | --outdir dist/ 67 | - name: Publish distribution 📦 to Test PyPI 68 | uses: pypa/gh-action-pypi-publish@master 69 | continue-on-error: true 70 | with: 71 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 72 | repository_url: https://test.pypi.org/legacy/ 73 | - name: Publish distribution 📦 to PyPI 74 | if: startsWith(github.ref, 'refs/tags') 75 | uses: pypa/gh-action-pypi-publish@master 76 | with: 77 | password: ${{ secrets.PYPI_API_TOKEN }} 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | .pytest_cache 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | .idea/ 57 | .pypirc 58 | 59 | .eggs/ 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # github will run `pre-commit run --hook-stage manual -a` and then manually black and flake8 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.1.0 5 | hooks: 6 | - id: check-yaml 7 | stages: [commit, push, manual] 8 | - id: check-json 9 | stages: [commit, push, manual] 10 | - id: end-of-file-fixer 11 | stages: [commit, push, manual] 12 | - id: trailing-whitespace 13 | stages: [commit, push, manual] 14 | - repo: local 15 | hooks: 16 | - id: isort 17 | name: isort 18 | entry: isort --profile black 19 | language: system 20 | types: [python] 21 | stages: [commit, push, manual] 22 | - id: black 23 | name: black 24 | entry: black 25 | language: system 26 | types: [python] 27 | stages: [commit, push, manual] 28 | - id: flake8 29 | name: flake8 30 | entry: flake8 31 | language: system 32 | types: [python] 33 | stages: [commit, push, manual] 34 | - id: tests 35 | name: pytest 36 | entry: pytest 37 | pass_filenames: false 38 | language: system 39 | types: [python] 40 | stages: [commit, push, manual] 41 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | http://github.com/Benoss/django-elasticsearch-debug-toolbar/contributors 2 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 3.1.0 2 | * Add a stacktrace to the records to show where the searches were originated #13 3 | 4 | 3.0.2 5 | * Just CI auto deploy fixes 6 | 7 | 3.0.0 8 | * Compatible with Debug toolbar 3.x 9 | * Compatible with Django 4.0 (Requires python 3.8+) 10 | * Remove python 3.6 and 3.7 11 | * Add Python 3.8 3.9 3.10 12 | * Remove travis switch to github actions 13 | 14 | 2.0.0 15 | * Remove python 2.x 16 | * Compatible with Debug toolbar 2.x 17 | 18 | 1.0.0 19 | Added duplicate detection and query hash 20 | 21 | 0.1.0 22 | * Initial release 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Benoit Chabord 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include elastic_panel/templates * 4 | recursive-include elastic_panel/static * 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Elasticsearch Toolbar 2 | ============================ 3 | 4 | A Django Debug Toolbar panel for Elasticsearch 5 | [![PyPI version](https://badge.fury.io/py/django-elasticsearch-debug-toolbar.svg)](https://badge.fury.io/py/django-elasticsearch-debug-toolbar) 6 | 7 | About 8 | ------------ 9 | 10 | Breaking changes: 11 | * django-elasticsearch-debug-toolbar 3.x is compatible with Django Debug Toolbar 3.x (elasticsearch <8.0.0) 12 | * django-elasticsearch-debug-toolbar 2.x is compatible with Django Debug Toolbar 2.x 13 | * django-elasticsearch-debug-toolbar 1.x is compatible with Django Debug Toolbar 1.x 14 | 15 | ElasticSearch queries using [elasticsearch python](https://github.com/elasticsearch/elasticsearch-py) official client. 16 | 17 | You are more than welcome to participate 18 | * Any idea and no time to code send your idea here: https://github.com/Benoss/django-elasticsearch-debug-toolbar/issues 19 | * An idea and the code just send a pull request here: https://github.com/Benoss/django-elasticsearch-debug-toolbar/pulls 20 | 21 | 22 | 23 | Installation 24 | ------------ 25 | 26 | Install using ``pip``:: 27 | 28 | pip install django-elasticsearch-debug-toolbar 29 | 30 | or install the development version from source:: 31 | 32 | pip install git+git@github.com:Benoss/django-elasticsearch-debug-toolbar.git 33 | 34 | * Then add ``elastic_panel`` to your ``INSTALLED_APPS`` so that we can find the templates in the panel. 35 | * Also, add ``'elastic_panel.panel.ElasticDebugPanel'`` to your ``DEBUG_TOOLBAR_PANELS``. 36 | 37 | Usage 38 | ------------ 39 | 40 | Just click the link in the Django Debug toolbar: 41 | 42 | ![elastic queries image](https://raw.github.com/Benoss/django-elasticsearch-debug-toolbar/master/doc/elastic_queries.png) 43 | 44 | License 45 | ------------ 46 | 47 | Uses the `MIT` license. 48 | 49 | * Django Debug Toolbar: https://github.com/django-debug-toolbar/django-debug-toolbar 50 | * MIT: http://opensource.org/licenses/MIT 51 | -------------------------------------------------------------------------------- /doc/elastic_queries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benoss/django-elasticsearch-debug-toolbar/d8d0ef8b62798cc5d75b1d9cea0ceb42f7b24de7/doc/elastic_queries.png -------------------------------------------------------------------------------- /elastic_panel/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | elastic_panel 5 | ~~~~~~~~~~~~~~ 6 | 7 | :copyright: (c) 2014 by Benoit Chabord 8 | :license: See LICENSE for more details. 9 | 10 | """ 11 | 12 | import pkg_resources 13 | 14 | try: 15 | __version__ = pkg_resources.get_distribution("django-elasticsearch-debug-toolbar").version 16 | except Exception: 17 | __version__ = "unknown" 18 | 19 | 20 | __title__ = "elastic_panel" 21 | __author__ = "Benoit Chabord" 22 | __copyright__ = "Copyright 2014 Benoit Chabord" 23 | 24 | VERSION = __version__ 25 | -------------------------------------------------------------------------------- /elastic_panel/panel.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | 4 | from debug_toolbar.panels import Panel 5 | from debug_toolbar.utils import ( 6 | ThreadCollector, 7 | get_module_path, 8 | get_stack, 9 | hidden_paths, 10 | render_stacktrace, 11 | tidy_stacktrace, 12 | ) 13 | from django.templatetags.static import static 14 | from django.utils.translation import gettext_lazy as _ 15 | from elasticsearch.connection.base import Connection 16 | 17 | # Patching og the original elasticsearch log_request 18 | old_log_request_success = Connection.log_request_success 19 | collector = ThreadCollector() 20 | 21 | 22 | def patched_log_request_success(self, method, full_url, path, body, status_code, response, duration): 23 | collector.collect(ElasticQueryInfo(method, full_url, path, body, status_code, response, duration)) 24 | old_log_request_success(self, method, full_url, path, body, status_code, response, duration) 25 | 26 | 27 | Connection.log_request_success = patched_log_request_success 28 | 29 | 30 | def _pretty_json(data): 31 | # pretty JSON in tracer curl logs 32 | try: 33 | return json.dumps(json.loads(data), sort_keys=True, indent=2, separators=(",", ": ")).replace("'", r"\u0027") 34 | except (ValueError, TypeError): 35 | # non-json data or a bulk request 36 | return data 37 | 38 | 39 | hidden_paths.append(get_module_path(__name__)) 40 | 41 | 42 | class ElasticQueryInfo: 43 | def __init__(self, method, full_url, path, body, status_code, response, duration): 44 | if not body: 45 | self.body = "" # Python 3 TypeError if None 46 | else: 47 | self.body = _pretty_json(body) 48 | if isinstance(self.body, bytes): 49 | self.body = self.body.decode("ascii", "ignore") 50 | self.method = method 51 | self.full_url = full_url 52 | self.path = path 53 | self.status_code = status_code 54 | self.response = _pretty_json(response) 55 | self.duration = round(duration * 1000, 2) 56 | self.hash = hashlib.md5( 57 | self.full_url.encode("ascii", "ignore") + self.body.encode("ascii", "ignore") 58 | ).hexdigest() 59 | self.stacktrace = tidy_stacktrace(reversed(get_stack())) 60 | 61 | 62 | class ElasticDebugPanel(Panel): 63 | """ 64 | Panel that displays queries made by Elasticsearch backends. 65 | """ 66 | 67 | name = "Elasticsearch" 68 | template = "elastic_panel/elastic_panel.html" 69 | has_content = True 70 | total_time = 0 71 | nb_duplicates = 0 72 | nb_queries = 0 73 | 74 | @property 75 | def nav_title(self): 76 | return _("Elastic Queries") 77 | 78 | @property 79 | def nav_subtitle(self): 80 | default_str = "{} queries {:.2f}ms".format(self.nb_queries, self.total_time) 81 | if self.nb_duplicates > 0: 82 | default_str += " {} DUPE".format(self.nb_duplicates) 83 | return default_str 84 | 85 | @property 86 | def title(self): 87 | return self.nav_title 88 | 89 | @property 90 | def scripts(self): 91 | scripts = super().scripts 92 | scripts.append(static("elastic_panel/js/elastic_panel.js")) 93 | return scripts 94 | 95 | def process_request(self, request): 96 | collector.clear_collection() 97 | return super().process_request(request) 98 | 99 | def generate_stats(self, request, response): 100 | records = collector.get_collection() 101 | self.total_time = 0 102 | self.nb_duplicates = 0 103 | 104 | hashs = set() 105 | for record in records: 106 | self.total_time += record.duration 107 | if record.hash in hashs: 108 | self.nb_duplicates += 1 109 | hashs.add(record.hash) 110 | record.stacktrace = render_stacktrace(record.stacktrace) 111 | 112 | self.nb_queries = len(records) 113 | 114 | collector.clear_collection() 115 | self.record_stats({"records": records}) 116 | -------------------------------------------------------------------------------- /elastic_panel/static/elastic_panel/js/elastic_panel.js: -------------------------------------------------------------------------------- 1 | var toggle = function(elem) { 2 | if (window.getComputedStyle(elem).display === 'block') { 3 | elem.style.display = 'none'; 4 | return; 5 | } 6 | 7 | elem.style.display = 'block'; 8 | }; 9 | 10 | var uarr = String.fromCharCode(0x25b6), 11 | darr = String.fromCharCode(0x25bc); 12 | 13 | var showHandler = function(e) { 14 | 15 | if(this.nextElementSibling) { 16 | toggle(this.nextElementSibling) 17 | } 18 | 19 | var arrow = this.children[0]; 20 | arrow.textContent = arrow.textContent == uarr ? darr : uarr 21 | 22 | toggle(this.parentNode.nextElementSibling) 23 | 24 | return false 25 | }; 26 | 27 | for (var e of document.querySelectorAll('a.elasticShowTemplate')) { 28 | e.addEventListener('click', showHandler) 29 | } 30 | 31 | var textHandler = function(e) { 32 | var selection = window.getSelection(); 33 | var range = document.createRange(); 34 | range.selectNodeContents(this.parentNode.nextElementSibling.querySelector('code')); 35 | selection.removeAllRanges(); 36 | selection.addRange(range); 37 | }; 38 | 39 | for (var e of document.querySelectorAll('.selectText')) { 40 | e.addEventListener('click', textHandler) 41 | } 42 | -------------------------------------------------------------------------------- /elastic_panel/templates/elastic_panel/elastic_panel.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | {#

{% trans 'Queries' %}

#} 5 | {% if records %} 6 |
7 | {% for record in records %} 8 |
9 | {{ record.method }} {{record.status_code}} {{record.path}} QueryHash-> {{record.hash}} 10 |
11 |
time: {{record.duration}} ms
12 |
full_url: {{record.full_url}}
13 |
14 | 20 | 23 |
24 |
25 | 31 | 34 |
35 |
36 | 41 | 46 |
47 | {% endfor %} 48 |
49 | {% else %} 50 | {% if debug %} 51 |

No Elastic queries were recorded during this request.

52 | {% else %} 53 |

54 | DEBUG is set to False. This means 55 | that Elastic query logging is disabled. 56 |

57 | {% endif %} 58 | {% endif %} 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version=["py39", ] 4 | include = '\.pyi?$' 5 | # see help text for default exclude 6 | exclude = '''/( 7 | # black defaults from --help (see also https://github.com/ambv/black#configuration-format ) 8 | \.git 9 | | \.hg 10 | | \.mypy_cache 11 | | \.nox 12 | | \.tox 13 | | \.venv 14 | | _build 15 | | buck-out 16 | | build 17 | | dist 18 | 19 | # common exclusions 20 | | node_modules 21 | 22 | # django specific exclusions 23 | 24 | | migrations 25 | 26 | # project specific exclusions 27 | )/ 28 | ''' 29 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | env = 3 | DJANGO_SETTINGS_MODULE=tests.settings 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | from setuptools import find_packages 4 | 5 | setup( 6 | name="django-elasticsearch-debug-toolbar", 7 | packages=find_packages(), 8 | version="3.0.2", 9 | description="A Django Debug Toolbar panel for Elasticsearch", 10 | long_description=open("README.md").read(), 11 | author="Benoit Chabord", 12 | author_email="benauf@gmail.com", 13 | url="http://github.com/Benoss/django-elasticsearch-debug-toolbar", 14 | license="MIT", 15 | keywords=["django", "es", "elastic", "elasticsearch"], 16 | include_package_data=True, 17 | classifiers=[ 18 | "Framework :: Django", 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | ], 27 | tests_require=["pytest", "django-debug-toolbar", "elasticsearch"], 28 | test_suite="pytest.collector", 29 | ) 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benoss/django-elasticsearch-debug-toolbar/d8d0ef8b62798cc5d75b1d9cea0ceb42f7b24de7/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | django-debug-toolbar>=3.0 2 | elasticsearch<8.0.0 3 | 4 | pytest 5 | pytest-env 6 | 7 | isort 8 | flake8 9 | flake8-print # Forbid print statement in code use logging. instead 10 | flake8-bugbear # Catch common errors 11 | flake8-printf-formatting # 12 | black>=22.1.0 13 | pre-commit 14 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 4 | 5 | INSTALLED_APPS = ["debug_toolbar", "elastic_panel"] 6 | 7 | DEBUG_TOOLBAR_PANELS = [ 8 | "elastic_panel.panel.ElasticDebugPanel", 9 | ] 10 | SECRET_KEY = "test" 11 | DEBUG = True 12 | 13 | DATABASES = { 14 | "default": { 15 | "ENGINE": "django.db.backends.sqlite3", 16 | "NAME": ":memory:", 17 | }, 18 | } 19 | 20 | TEMPLATES = [ 21 | { 22 | "BACKEND": "django.template.backends.django.DjangoTemplates", 23 | "DIRS": [BASE_DIR + "/tests/templates"], 24 | "APP_DIRS": True, 25 | }, 26 | ] 27 | -------------------------------------------------------------------------------- /tests/test_toolbar.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import django 4 | 5 | django.setup() 6 | 7 | 8 | from debug_toolbar.toolbar import DebugToolbar # noqa: E402 9 | from django.http import HttpResponse # noqa: E402 10 | from django.test import RequestFactory # noqa: E402 11 | from django.test import TestCase 12 | from elasticsearch.connection import Connection # noqa: E402 13 | 14 | from elastic_panel import panel # noqa: E402 15 | 16 | 17 | class ImportTest(TestCase): 18 | def test_input(self): 19 | panel.ElasticQueryInfo("GET", "asdasd", "asdasd", "{}", 200, "adssad", 1) 20 | panel.ElasticQueryInfo("GET", "asdasd", "asdasd", "", 200, "adssad", 1) 21 | panel.ElasticQueryInfo("GET", "asdasd", "asdasd", None, 200, "adssad", 1) 22 | panel.ElasticQueryInfo("GET", "asdasd", "asdasd", "{'asddsa': 'é'}", 200, "adssad", 1) 23 | panel.ElasticQueryInfo("GET", "asdasd", "asdasd", b"{'asddsa': 'asddasds'}", 200, "adssad", 1) 24 | 25 | 26 | class PanelTests(TestCase): 27 | def setUp(self): 28 | self.get_response = lambda request: HttpResponse() 29 | self.request = RequestFactory().get("/") 30 | self.toolbar = DebugToolbar(self.request, self.get_response) 31 | self.panel = panel.ElasticDebugPanel(self.toolbar, self.get_response) 32 | 33 | def test_recording(self, *args): 34 | response = self.panel.process_request(self.request) 35 | Connection().log_request_success("GET", "asdasd", "asdasd", "{}", 200, "adssad", 1) 36 | self.assertIsNotNone(response) 37 | 38 | self.panel.generate_stats(self.request, response) 39 | stats = self.panel.get_stats() 40 | self.assertIn("records", stats) 41 | self.assertEqual(len(stats["records"]), 1) 42 | 43 | 44 | if __name__ == "__main__": 45 | unittest.main() 46 | --------------------------------------------------------------------------------