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..fc75763f7 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -384,8 +384,12 @@ 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 + .. automethod:: debug_toolbar.panels.Panel.run_checks .. _javascript-api: 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}) 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(