From c06605dfcfe7beaa9d88b870c3c5178a43363ee5 Mon Sep 17 00:00:00 2001 From: Ben Beecher Date: Tue, 28 Dec 2021 15:52:10 -0500 Subject: [PATCH 1/3] Adding Update on ajax feature This adds a feature to allow the toolbar to watch for ajax requests and automatically update debug information to show ajax details Adding debounce for pulling new panel info Adding heuristic function for observing requests Also adding generate_headers function Adding option to include historical data, remove signature Updated header name to match existing djdt namespace Move header value creation into the panels. This moves more logic into the Panel class and gives greater control to the Panel subclasses on how things should work. Move get_observe_request to DebugToolbar This avoids having to import the function within a panel. Rename do_not_include_history to exclude_history Add maxsize for lru_cache for python3.7 Clean up history.js documentation and remove unnecessary return. --- debug_toolbar/middleware.py | 31 ++++++---------- debug_toolbar/panels/__init__.py | 33 +++++++++++++++-- debug_toolbar/panels/history/forms.py | 1 + debug_toolbar/panels/history/panel.py | 17 ++++++++- debug_toolbar/panels/history/views.py | 10 ++++- debug_toolbar/settings.py | 1 + .../static/debug_toolbar/js/history.js | 37 ++++++++----------- .../static/debug_toolbar/js/toolbar.js | 25 ++++++++++++- .../static/debug_toolbar/js/utils.js | 32 +++++++++++++++- .../templates/debug_toolbar/base.html | 4 ++ debug_toolbar/toolbar.py | 21 ++++++++++- docs/changes.rst | 1 + docs/configuration.rst | 12 ++++++ docs/panels.rst | 2 + tests/panels/test_history.py | 25 +++++++++++-- 15 files changed, 195 insertions(+), 57 deletions(-) diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index df4516b4f..f131861fc 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -67,12 +67,13 @@ def __call__(self, request): panel.generate_stats(request, response) panel.generate_server_timing(request, response) - response = self.generate_server_timing_header(response, toolbar.enabled_panels) - # Always render the toolbar for the history panel, even if it is not # included in the response. rendered = toolbar.render_toolbar() + for header, value in self.get_headers(request, toolbar.enabled_panels).items(): + response.headers[header] = value + # Check for responses where the toolbar can't be inserted. content_encoding = response.get("Content-Encoding", "") content_type = response.get("Content-Type", "").split(";")[0] @@ -96,22 +97,12 @@ def __call__(self, request): return response @staticmethod - def generate_server_timing_header(response, panels): - data = [] - + def get_headers(request, panels): + headers = {} for panel in panels: - stats = panel.get_server_timing_stats() - if not stats: - continue - - for key, record in stats.items(): - # example: `SQLPanel_sql_time;dur=0;desc="SQL 0 queries"` - data.append( - '{}_{};dur={};desc="{}"'.format( - panel.panel_id, key, record.get("value"), record.get("title") - ) - ) - - if data: - response["Server-Timing"] = ", ".join(data) - return response + for header, value in panel.get_headers(request).items(): + if header in headers: + headers[header] += f", {value}" + else: + headers[header] = value + return headers diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 8fd433c63..ce6772ec6 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -192,16 +192,41 @@ def process_request(self, request): """ return self.get_response(request) - def generate_stats(self, request, response): + def get_headers(self, request): """ + Get headers the panel needs to set. + Called after :meth:`process_request - `, but may not be executed - on every request. This will only be called if the toolbar will be - inserted into the request. + ` and + :meth:`process_request` + + Header values will be appended if multiple panels need to set it. + + By default it sets the Server-Timing header. + + Return dict of headers to be appended. + """ + headers = {} + stats = self.get_server_timing_stats() + if stats: + headers["Server-Timing"] = ", ".join( + # example: `SQLPanel_sql_time;dur=0;desc="SQL 0 queries"` + '{}_{};dur={};desc="{}"'.format( + self.panel_id, key, record.get("value"), record.get("title") + ) + for key, record in stats.items() + ) + return headers + def generate_stats(self, request, response): + """ Write panel logic related to the response there. Post-process data gathered while the view executed. Save data with :meth:`record_stats`. + Called after :meth:`process_request + `. + + Does not return a value. """ diff --git a/debug_toolbar/panels/history/forms.py b/debug_toolbar/panels/history/forms.py index 9280c3cc9..952b2409d 100644 --- a/debug_toolbar/panels/history/forms.py +++ b/debug_toolbar/panels/history/forms.py @@ -9,3 +9,4 @@ class HistoryStoreForm(forms.Form): """ store_id = forms.CharField(widget=forms.HiddenInput()) + exclude_history = forms.BooleanField(widget=forms.HiddenInput(), required=False) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index bde30e74f..00b350b3c 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -20,6 +20,14 @@ class HistoryPanel(Panel): nav_title = _("History") template = "debug_toolbar/panels/history.html" + def get_headers(self, request): + headers = super().get_headers(request) + observe_request = self.toolbar.get_observe_request() + store_id = getattr(self.toolbar, "store_id") + if store_id and observe_request(request): + headers["DJDT-STORE-ID"] = store_id + return headers + @property def enabled(self): # Do not show the history panel if the panels are rendered on request @@ -83,7 +91,9 @@ def content(self): for id, toolbar in reversed(self.toolbar._store.items()): stores[id] = { "toolbar": toolbar, - "form": HistoryStoreForm(initial={"store_id": id}), + "form": HistoryStoreForm( + initial={"store_id": id, "exclude_history": True} + ), } return render_to_string( @@ -92,7 +102,10 @@ def content(self): "current_store_id": self.toolbar.store_id, "stores": stores, "refresh_form": HistoryStoreForm( - initial={"store_id": self.toolbar.store_id} + initial={ + "store_id": self.toolbar.store_id, + "exclude_history": True, + } ), }, ) diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py index d452fd6e0..d50841a53 100644 --- a/debug_toolbar/panels/history/views.py +++ b/debug_toolbar/panels/history/views.py @@ -14,13 +14,14 @@ def history_sidebar(request): if form.is_valid(): store_id = form.cleaned_data["store_id"] toolbar = DebugToolbar.fetch(store_id) + exclude_history = form.cleaned_data["exclude_history"] context = {} if toolbar is None: # When the store_id has been popped already due to # RESULTS_CACHE_SIZE return JsonResponse(context) for panel in toolbar.panels: - if not panel.is_historical: + if exclude_history and not panel.is_historical: continue panel_context = {"panel": panel} context[panel.panel_id] = { @@ -53,7 +54,12 @@ def history_refresh(request): "id": id, "store_context": { "toolbar": toolbar, - "form": HistoryStoreForm(initial={"store_id": id}), + "form": HistoryStoreForm( + initial={ + "store_id": id, + "exclude_history": True, + } + ), }, }, ), diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index aac87e6ba..5bf9bb09f 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -37,6 +37,7 @@ "SHOW_TEMPLATE_CONTEXT": True, "SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"), "SQL_WARNING_THRESHOLD": 500, # milliseconds + "OBSERVE_REQUEST_CALLBACK": "debug_toolbar.toolbar.observe_request", } diff --git a/debug_toolbar/static/debug_toolbar/js/history.js b/debug_toolbar/static/debug_toolbar/js/history.js index a356c3fcd..b30fcabae 100644 --- a/debug_toolbar/static/debug_toolbar/js/history.js +++ b/debug_toolbar/static/debug_toolbar/js/history.js @@ -1,4 +1,4 @@ -import { $$, ajaxForm } from "./utils.js"; +import { $$, ajaxForm, replaceToolbarState } from "./utils.js"; const djDebug = document.getElementById("djDebug"); @@ -12,9 +12,6 @@ function difference(setA, setB) { /** * Create an array of dataset properties from a NodeList. - * @param nodes - * @param key - * @returns {[]} */ function pluckData(nodes, key) { const data = []; @@ -31,7 +28,7 @@ function refreshHistory() { pluckData(container.querySelectorAll("tr[data-store-id]"), "storeId") ); - return ajaxForm(formTarget) + ajaxForm(formTarget) .then(function (data) { // Remove existing rows first then re-populate with new data container @@ -75,36 +72,32 @@ function refreshHistory() { }); } -$$.on(djDebug, "click", ".switchHistory", function (event) { - event.preventDefault(); - const newStoreId = this.dataset.storeId; - const tbody = this.closest("tbody"); +function switchHistory(newStoreId) { + const formTarget = djDebug.querySelector( + ".switchHistory[data-store-id='" + newStoreId + "']" + ); + const tbody = formTarget.closest("tbody"); const highlighted = tbody.querySelector(".djdt-highlighted"); if (highlighted) { highlighted.classList.remove("djdt-highlighted"); } - this.closest("tr").classList.add("djdt-highlighted"); + formTarget.closest("tr").classList.add("djdt-highlighted"); - ajaxForm(this).then(function (data) { - djDebug.setAttribute("data-store-id", newStoreId); - // Check if response is empty, it could be due to an expired store_id. + ajaxForm(formTarget).then(function (data) { if (Object.keys(data).length === 0) { const container = document.getElementById("djdtHistoryRequests"); container.querySelector( 'button[data-store-id="' + newStoreId + '"]' ).innerHTML = "Switch [EXPIRED]"; - } else { - Object.keys(data).forEach(function (panelId) { - const panel = document.getElementById(panelId); - if (panel) { - panel.outerHTML = data[panelId].content; - document.getElementById("djdt-" + panelId).outerHTML = - data[panelId].button; - } - }); } + replaceToolbarState(newStoreId, data); }); +} + +$$.on(djDebug, "click", ".switchHistory", function (event) { + event.preventDefault(); + switchHistory(this.dataset.storeId); }); $$.on(djDebug, "click", ".refreshHistory", function (event) { diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index c17ee3ea2..860c72110 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -1,4 +1,4 @@ -import { $$, ajax } from "./utils.js"; +import { $$, ajax, replaceToolbarState, debounce } from "./utils.js"; function onKeyDown(event) { if (event.keyCode === 27) { @@ -200,6 +200,9 @@ const djdt = { } else { djdt.hide_toolbar(); } + if (djDebug.dataset.sidebarUrl !== undefined) { + djdt.update_on_ajax(); + } }, hide_panels() { const djDebug = document.getElementById("djDebug"); @@ -253,6 +256,26 @@ const djdt = { localStorage.setItem("djdt.show", "true"); window.removeEventListener("resize", djdt.ensure_handle_visibility); }, + update_on_ajax() { + const sidebar_url = + document.getElementById("djDebug").dataset.sidebarUrl; + const slowjax = debounce(ajax, 200); + + const origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function () { + this.addEventListener("load", function () { + let store_id = this.getResponseHeader("djdt-store-id"); + if (store_id !== null) { + store_id = encodeURIComponent(store_id); + const dest = `${sidebar_url}?store_id=${store_id}`; + slowjax(dest).then(function (data) { + replaceToolbarState(store_id, data); + }); + } + }); + origOpen.apply(this, arguments); + }; + }, cookie: { get(key) { if (!document.cookie.includes(key)) { diff --git a/debug_toolbar/static/debug_toolbar/js/utils.js b/debug_toolbar/static/debug_toolbar/js/utils.js index da810aad0..72c767fb6 100644 --- a/debug_toolbar/static/debug_toolbar/js/utils.js +++ b/debug_toolbar/static/debug_toolbar/js/utils.js @@ -104,4 +104,34 @@ function ajaxForm(element) { return ajax(url, ajaxData); } -export { $$, ajax, ajaxForm }; +function replaceToolbarState(newStoreId, data) { + const djDebug = document.getElementById("djDebug"); + djDebug.setAttribute("data-store-id", newStoreId); + // Check if response is empty, it could be due to an expired store_id. + Object.keys(data).forEach(function (panelId) { + const panel = document.getElementById(panelId); + if (panel) { + panel.outerHTML = data[panelId].content; + document.getElementById("djdt-" + panelId).outerHTML = + data[panelId].button; + } + }); +} + +function debounce(func, delay) { + let timer = null; + let resolves = []; + + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => { + const result = func(...args); + resolves.forEach((r) => r(result)); + resolves = []; + }, delay); + + return new Promise((r) => resolves.push(r)); + }; +} + +export { $$, ajax, ajaxForm, replaceToolbarState, debounce }; diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index 7abc5476f..5447970af 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -11,6 +11,10 @@ data-store-id="{{ toolbar.store_id }}" data-render-panel-url="{% url 'djdt:render_panel' %}" {% endif %} + {% url 'djdt:history_sidebar' as history_url %} + {% if history_url %} + data-sidebar-url="{{ history_url }}" + {% endif %} data-default-show="{% if toolbar.config.SHOW_COLLAPSED %}false{% else %}true{% endif %}" {{ toolbar.config.ROOT_TAG_EXTRA_ATTRS|safe }}>
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 5f6fdd273..f8ea05594 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -4,6 +4,7 @@ import uuid from collections import OrderedDict +from functools import lru_cache from django.apps import apps from django.core.exceptions import ImproperlyConfigured @@ -130,7 +131,7 @@ def get_urls(cls): # Load URLs in a temporary variable for thread safety. # Global URLs urlpatterns = [ - path("render_panel/", views.render_panel, name="render_panel") + path("render_panel/", views.render_panel, name="render_panel"), ] # Per-panel URLs for panel_class in cls.get_panel_classes(): @@ -154,3 +155,21 @@ def is_toolbar_request(cls, request): except Resolver404: return False return resolver_match.namespaces and resolver_match.namespaces[-1] == app_name + + @staticmethod + @lru_cache(maxsize=128) + def get_observe_request(): + # If OBSERVE_REQUEST_CALLBACK is a string, which is the recommended + # setup, resolve it to the corresponding callable. + func_or_path = dt_settings.get_config()["OBSERVE_REQUEST_CALLBACK"] + if isinstance(func_or_path, str): + return import_string(func_or_path) + else: + return func_or_path + + +def observe_request(request): + """ + Determine whether to update the toolbar from a client side request. + """ + return not DebugToolbar.is_toolbar_request(request) diff --git a/docs/changes.rst b/docs/changes.rst index 83bfb1d78..6fc9f95fa 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -13,6 +13,7 @@ Next version ``@override_settings``, to reconfigure the toolbar during tests. * Optimize rendering of SQL panel, saving about 30% of its run time. * New records in history panel will flash green. +* Automatically update History panel on AJAX requests from client. 3.2.4 (2021-12-15) ------------------ diff --git a/docs/configuration.rst b/docs/configuration.rst index 87b68b77f..7577be62d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -138,6 +138,18 @@ Toolbar options implication is that it is possible to execute arbitrary SQL through the SQL panel when the ``SECRET_KEY`` value is leaked somehow. +.. _OBSERVE_REQUEST_CALLBACK: + +* ``OBSERVE_REQUEST_CALLBACK`` + + Default: ``'debug_toolbar.middleware.observe_request'`` + + This is the dotted path to a function used for determining whether the + toolbar should update on AJAX requests or not. The default checks are that + the request doesn't originate from the toolbar itself, EG that + ``is_toolbar_request`` is false for a given request. + + Panel options ~~~~~~~~~~~~~ diff --git a/docs/panels.rst b/docs/panels.rst index 6dc3ca9f8..9c56f1ace 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -386,6 +386,8 @@ unauthorized access. There is no public CSS API at this time. .. automethod:: debug_toolbar.panels.Panel.generate_stats + .. automethod:: debug_toolbar.panels.Panel.get_headers + .. automethod:: debug_toolbar.panels.Panel.run_checks .. _javascript-api: diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index 06613d756..f6986cafb 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -1,3 +1,4 @@ +import copy import html from django.test import RequestFactory, override_settings @@ -92,6 +93,7 @@ def test_history_panel_integration_content(self): toolbar = list(DebugToolbar._store.values())[0] content = toolbar.get_panel_by_id("HistoryPanel").content self.assertIn("bar", content) + self.assertIn('name="exclude_history" value="True"', content) def test_history_sidebar_invalid(self): response = self.client.get(reverse("djdt:history_sidebar")) @@ -101,7 +103,7 @@ def test_history_sidebar(self): """Validate the history sidebar view.""" self.client.get("/json_view/") store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id} + data = {"store_id": store_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -109,6 +111,21 @@ def test_history_sidebar(self): self.PANEL_KEYS, ) + def test_history_sidebar_includes_history(self): + """Validate the history sidebar view.""" + self.client.get("/json_view/") + panel_keys = copy.copy(self.PANEL_KEYS) + panel_keys.add("HistoryPanel") + panel_keys.add("RedirectsPanel") + store_id = list(DebugToolbar._store)[0] + data = {"store_id": store_id} + response = self.client.get(reverse("djdt:history_sidebar"), data=data) + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.json()), + panel_keys, + ) + @override_settings( DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1, "RENDER_PANELS": False} ) @@ -116,7 +133,7 @@ def test_history_sidebar_expired_store_id(self): """Validate the history sidebar view.""" self.client.get("/json_view/") store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id} + data = {"store_id": store_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -126,14 +143,14 @@ def test_history_sidebar_expired_store_id(self): self.client.get("/json_view/") # Querying old store_id should return in empty response - data = {"store_id": store_id} + data = {"store_id": store_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {}) # Querying with latest store_id latest_store_id = list(DebugToolbar._store)[0] - data = {"store_id": latest_store_id} + data = {"store_id": latest_store_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( From f66a8d0cbcd6f179ab84b5ec5c90894d681b193f Mon Sep 17 00:00:00 2001 From: tschilling Date: Sat, 19 Feb 2022 09:39:11 -0600 Subject: [PATCH 2/3] Add generate_server_timing docs for Panel. --- docs/panels.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/panels.rst b/docs/panels.rst index 9c56f1ace..fc75763f7 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -384,6 +384,8 @@ unauthorized access. There is no public CSS API at this time. .. automethod:: debug_toolbar.panels.Panel.process_request + .. automethod:: debug_toolbar.panels.Panel.generate_server_timing + .. automethod:: debug_toolbar.panels.Panel.generate_stats .. automethod:: debug_toolbar.panels.Panel.get_headers From 719902c24ab319aa75c086a5845a9fcf054c2bf1 Mon Sep 17 00:00:00 2001 From: tschilling Date: Sun, 20 Feb 2022 07:57:48 -0600 Subject: [PATCH 3/3] Add AJAX view and control to example app. --- example/templates/index.html | 16 ++++++++++++++++ example/urls.py | 3 +++ example/views.py | 10 ++++++++++ 3 files changed, 29 insertions(+) create mode 100644 example/views.py diff --git a/example/templates/index.html b/example/templates/index.html index 1616d3248..3f60cefce 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -15,5 +15,21 @@

Index of Tests

Django Admin

{% endcache %} +

+ Value + {{ request.session.value|default:0 }} + +

+ diff --git a/example/urls.py b/example/urls.py index 7a2b56857..1bef284f0 100644 --- a/example/urls.py +++ b/example/urls.py @@ -2,11 +2,14 @@ from django.urls import include, path from django.views.generic import TemplateView +from example.views import increment + urlpatterns = [ path("", TemplateView.as_view(template_name="index.html")), path("jquery/", TemplateView.as_view(template_name="jquery/index.html")), path("mootools/", TemplateView.as_view(template_name="mootools/index.html")), path("prototype/", TemplateView.as_view(template_name="prototype/index.html")), path("admin/", admin.site.urls), + path("ajax/increment", increment, name="ajax_increment"), path("__debug__/", include("debug_toolbar.urls")), ] diff --git a/example/views.py b/example/views.py new file mode 100644 index 000000000..46136515e --- /dev/null +++ b/example/views.py @@ -0,0 +1,10 @@ +from django.http import JsonResponse + + +def increment(request): + try: + value = int(request.session.get("value", 0)) + 1 + except ValueError: + value = 1 + request.session["value"] = value + return JsonResponse({"value": value})