Skip to content

Commit

Permalink
Support HistoryPanel to capture ajax requests.
Browse files Browse the repository at this point in the history
This creates a new panel, HistoryPanel which makes use of the
Toolbar.store to support a history of the toolbar as requests are
made.

The interface changes as follows:
Panel.is_historical - Indicates that the panel's button and content
should be updated when switching between snapshots of the history.

Toolbar.store - Will no longer generate a new store_id when the
instance already has a value.

DEBUG_TOOLBAR_CONFIG.HISTORY_POST_TRUNC_LENGTH - Allows the request's
POST content to be truncated in the history panel's content.

LoggingPanel and StaticFilesPanel now utilize the ``get_stats`` method
to fetch panel data for nav_subtitle.

Credit to @djsutho for creating the original third party panel:
https://github.com/djsutho/django-debug-toolbar-request-history
The core concepts were derived from that package.
  • Loading branch information
tim-schilling committed Mar 20, 2020
1 parent bad48b2 commit 7a4ea58
Show file tree
Hide file tree
Showing 23 changed files with 443 additions and 61 deletions.
22 changes: 10 additions & 12 deletions debug_toolbar/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
# Decide whether the toolbar is active for this request. Don't render
# the toolbar during AJAX requests.
# Decide whether the toolbar is active for this request.
show_toolbar = get_show_toolbar()
if not show_toolbar(request) or request.is_ajax():
if not show_toolbar(request):
return self.get_response(request)

toolbar = DebugToolbar(request, self.get_response)
Expand All @@ -65,6 +64,14 @@ def __call__(self, request):
for panel in reversed(toolbar.enabled_panels):
panel.disable_instrumentation()

# Generate the stats for all requests when the toolbar is being shown,
# but not necessarily inserted.
for panel in reversed(toolbar.enabled_panels):
panel.generate_stats(request, response)
panel.generate_server_timing(request, response)

response = self.generate_server_timing_header(response, toolbar.enabled_panels)

# Check for responses where the toolbar can't be inserted.
content_encoding = response.get("Content-Encoding", "")
content_type = response.get("Content-Type", "").split(";")[0]
Expand All @@ -87,15 +94,6 @@ def __call__(self, request):
pattern = re.escape(insert_before)
bits = re.split(pattern, content, flags=re.IGNORECASE)
if len(bits) > 1:
# When the toolbar will be inserted for sure, generate the stats.
for panel in reversed(toolbar.enabled_panels):
panel.generate_stats(request, response)
panel.generate_server_timing(request, response)

response = self.generate_server_timing_header(
response, toolbar.enabled_panels
)

bits[-2] += toolbar.render_toolbar()
response.content = insert_before.join(bits)
if response.get("Content-Length", None):
Expand Down
9 changes: 9 additions & 0 deletions debug_toolbar/panels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ def has_content(self):
"""
return True

@property
def is_historical(self):
"""
Panel supports rendering historical values.
Defaults to :attr:`has_content`.
"""
return self.has_content

@property
def title(self):
"""
Expand Down
1 change: 1 addition & 0 deletions debug_toolbar/panels/history/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from debug_toolbar.panels.history.panel import HistoryPanel # noqa
40 changes: 40 additions & 0 deletions debug_toolbar/panels/history/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import hashlib
import hmac

from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes


class HistoryStoreForm(forms.Form):
"""
Validate params
store_id: The key for the store instance to be fetched.
"""

store_id = forms.CharField(widget=forms.HiddenInput())
hash = forms.CharField(widget=forms.HiddenInput())

def __init__(self, *args, **kwargs):
initial = kwargs.get("initial", None)

if initial is not None:
initial["hash"] = self.make_hash(initial)

super().__init__(*args, **kwargs)

def make_hash(self, data):
m = hmac.new(key=force_bytes(settings.SECRET_KEY), digestmod=hashlib.sha1)
m.update(force_bytes(data["store_id"]))
return m.hexdigest()

def clean_hash(self):
hash = self.cleaned_data["hash"]

if not constant_time_compare(hash, self.make_hash(self.data)):
raise ValidationError("Tamper alert")

return hash
75 changes: 75 additions & 0 deletions debug_toolbar/panels/history/panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import json
import logging
from collections import OrderedDict

from django.conf.urls import url
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from debug_toolbar import settings as dt_settings
from debug_toolbar.panels import Panel
from debug_toolbar.panels.history import views
from debug_toolbar.panels.history.forms import HistoryStoreForm

logger = logging.getLogger(__name__)

CLEANSED_SUBSTITUTE = "********************"


class HistoryPanel(Panel):
""" A panel to display History """

title = _("History")
nav_title = _("History")
template = "debug_toolbar/panels/history.html"

@property
def is_historical(self):
"""The HistoryPanel should not be included in the historical panels."""
return False

@classmethod
def get_urls(cls):
return [
url(r"^history_sidebar/$", views.history_sidebar, name="history_sidebar"),
]

@property
def nav_subtitle(self):
return self.get_stats().get("request_url", "")

def generate_stats(self, request, response):
cleansed = request.POST.copy()
for k in cleansed:
cleansed[k] = CLEANSED_SUBSTITUTE
self.record_stats(
{
"request_url": request.get_full_path(),
"request_method": request.method,
"post": json.dumps(cleansed, sort_keys=True, indent=4),
"time": timezone.now(),
}
)

@property
def content(self):
"""Content of the panel when it's displayed in full screen.
Fetch every store for the toolbar and include it in the template.
"""
stores = OrderedDict()
for id, toolbar in reversed(self.toolbar._store.items()):
stores[id] = {
"toolbar": toolbar,
"form": HistoryStoreForm(initial={"store_id": id}),
}

return render_to_string(
self.template,
{
"current_store_id": self.toolbar.store_id,
"stores": stores,
"trunc_length": dt_settings.get_config()["HISTORY_POST_TRUNC_LENGTH"],
},
)
33 changes: 33 additions & 0 deletions debug_toolbar/panels/history/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from django.http import HttpResponseBadRequest, JsonResponse
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt

from debug_toolbar.decorators import require_show_toolbar
from debug_toolbar.panels.history.forms import HistoryStoreForm
from debug_toolbar.toolbar import DebugToolbar


@csrf_exempt
@require_show_toolbar
def history_sidebar(request):
"""Returns the selected debug toolbar history snapshot."""
form = HistoryStoreForm(request.POST or None)

if form.is_valid():
store_id = form.cleaned_data["store_id"]
toolbar = DebugToolbar.fetch(store_id)
context = {}
for panel in toolbar.panels:
if not panel.is_historical:
continue
panel_context = {"panel": panel}
context[panel.panel_id] = {
"button": render_to_string(
"debug_toolbar/includes/panel_button.html", panel_context
),
"content": render_to_string(
"debug_toolbar/includes/panel_content.html", panel_context
),
}
return JsonResponse(context)
return HttpResponseBadRequest("Form errors")
4 changes: 2 additions & 2 deletions debug_toolbar/panels/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ def __init__(self, *args, **kwargs):

@property
def nav_subtitle(self):
records = self._records[threading.currentThread()]
record_count = len(records)
stats = self.get_stats()
record_count = len(stats["records"]) if stats else None
return __("%(count)s message", "%(count)s messages", record_count) % {
"count": record_count
}
Expand Down
5 changes: 3 additions & 2 deletions debug_toolbar/panels/staticfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ def disable_instrumentation(self):

@property
def num_used(self):
return len(self._paths[threading.currentThread()])
stats = self.get_stats()
return stats and stats["num_used"]

nav_title = _("Static files")

Expand All @@ -121,7 +122,7 @@ def generate_stats(self, request, response):
self.record_stats(
{
"num_found": self.num_found,
"num_used": self.num_used,
"num_used": len(used_paths),
"staticfiles": used_paths,
"staticfiles_apps": self.get_staticfiles_apps(),
"staticfiles_dirs": self.get_staticfiles_dirs(),
Expand Down
2 changes: 2 additions & 0 deletions debug_toolbar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"django.utils.deprecation",
"django.utils.functional",
),
"HISTORY_POST_TRUNC_LENGTH": 0,
"PROFILER_MAX_DEPTH": 10,
"SHOW_TEMPLATE_CONTEXT": True,
"SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"),
Expand All @@ -53,6 +54,7 @@ def get_config():


PANELS_DEFAULTS = [
"debug_toolbar.panels.history.HistoryPanel",
"debug_toolbar.panels.versions.VersionsPanel",
"debug_toolbar.panels.timer.TimerPanel",
"debug_toolbar.panels.settings.SettingsPanel",
Expand Down
45 changes: 44 additions & 1 deletion debug_toolbar/static/debug_toolbar/js/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@
});
};

var ajaxJson = function(url, init) {
init = Object.assign({credentials: 'same-origin'}, init);
return fetch(url, init).then(function(response) {
if (response.ok) {
return response.json();
} else {
return Promise.reject();
}
});
}

var djdt = {
handleDragged: false,
events: {
Expand All @@ -63,7 +74,7 @@
init: function() {
var djDebug = document.querySelector('#djDebug');
$$.show(djDebug);
$$.on(djDebug.querySelector('#djDebugPanelList'), 'click', 'li a', function(event) {
$$.on(djDebug, 'click', '#djDebugPanelList li a', function(event) {
event.preventDefault();
if (!this.className) {
return;
Expand Down Expand Up @@ -133,6 +144,38 @@
});
});

// Used by the history panel
$$.on(djDebug, 'click', '.switchHistory', function(event) {
event.preventDefault();

var name = this.tagName.toLowerCase();
var ajax_data = {};
var newStoreId = this.dataset.storeId;


var form = this.closest('form');
ajax_data.url = this.getAttribute('formaction');

if (form) {
ajax_data.body = new FormData(form);
ajax_data.method = form.getAttribute('method') || 'POST';
}

var tbody = this.closest('tbody')
tbody.querySelector('.djdt-highlighted').classList.remove('djdt-highlighted')
this.closest('tr').classList.add('djdt-highlighted')

ajaxJson(ajax_data.url, ajax_data).then(function(data) {
djDebug.setAttribute('data-store-id', newStoreId);
for (var panelId in data) {
if (data.hasOwnProperty(panelId) && djDebug.querySelector('#'+panelId)) {
djDebug.querySelector('#'+panelId).outerHTML = data[panelId].content
djDebug.querySelector('.djdt-'+panelId).outerHTML = data[panelId].button
}
}
});
});

// Used by the cache, profiling and SQL panels
$$.on(djDebug, 'click', 'a.djToggleSwitch', function(event) {
event.preventDefault();
Expand Down
37 changes: 2 additions & 35 deletions debug_toolbar/templates/debug_toolbar/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,7 @@
<li id="djDebugButton">DEBUG</li>
{% endif %}
{% for panel in toolbar.panels %}
<li class="djDebugPanelButton">
<input type="checkbox" data-cookie="djdt{{ panel.panel_id }}" {% if panel.enabled %}checked title="{% trans "Disable for next and successive requests" %}"{% else %}title="{% trans "Enable for next and successive requests" %}"{% endif %}>
{% if panel.has_content and panel.enabled %}
<a href="#" title="{{ panel.title }}" class="{{ panel.panel_id }}">
{% else %}
<div class="djdt-contentless{% if not panel.enabled %} djdt-disabled{% endif %}">
{% endif %}
{{ panel.nav_title }}
{% if panel.enabled %}
{% with panel.nav_subtitle as subtitle %}
{% if subtitle %}<br><small>{{ subtitle }}</small>{% endif %}
{% endwith %}
{% endif %}
{% if panel.has_content and panel.enabled %}
</a>
{% else %}
</div>
{% endif %}
</li>
{% include "debug_toolbar/includes/panel_button.html" %}
{% endfor %}
</ul>
</div>
Expand All @@ -44,22 +26,7 @@
</div>
</div>
{% for panel in toolbar.panels %}
{% if panel.has_content and panel.enabled %}
<div id="{{ panel.panel_id }}" class="djdt-panelContent">
<div class="djDebugPanelTitle">
<a href="" class="djDebugClose">×</a>
<h3>{{ panel.title|safe }}</h3>
</div>
<div class="djDebugPanelContent">
{% if toolbar.store_id %}
<img src="{% static 'debug_toolbar/img/ajax-loader.gif' %}" alt="loading" class="djdt-loader">
<div class="djdt-scroll"></div>
{% else %}
<div class="djdt-scroll">{{ panel.content }}</div>
{% endif %}
</div>
</div>
{% endif %}
{% include "debug_toolbar/includes/panel_content.html" %}
{% endfor %}
<div id="djDebugWindow" class="djdt-panelContent"></div>
</div>
20 changes: 20 additions & 0 deletions debug_toolbar/templates/debug_toolbar/includes/panel_button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% load i18n %}
<li class="djDebugPanelButton djdt-{{ panel.panel_id }}">
<input type="checkbox" data-cookie="djdt{{ panel.panel_id }}" {% if panel.enabled %}checked title="{% trans "Disable for next and successive requests" %}"{% else %}title="{% trans "Enable for next and successive requests" %}"{% endif %}>
{% if panel.has_content and panel.enabled %}
<a href="#" title="{{ panel.title }}" class="{{ panel.panel_id }}">
{% else %}
<div class="djdt-contentless{% if not panel.enabled %} djdt-disabled{% endif %}">
{% endif %}
{{ panel.nav_title }}
{% if panel.enabled %}
{% with panel.nav_subtitle as subtitle %}
{% if subtitle %}<br><small>{{ subtitle }}</small>{% endif %}
{% endwith %}
{% endif %}
{% if panel.has_content and panel.enabled %}
</a>
{% else %}
</div>
{% endif %}
</li>
Loading

0 comments on commit 7a4ea58

Please sign in to comment.