Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HistoryPanel to capture ajax requests. #1250

Merged
merged 4 commits into from
Aug 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 14 additions & 17 deletions debug_toolbar/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import re
from functools import lru_cache

import django
from django.conf import settings
from django.utils.module_loading import import_string

Expand Down Expand Up @@ -43,14 +42,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 django.VERSION < (3, 1)
else not request.accepts("text/html")
):
if not show_toolbar(request) or request.path.startswith("/__debug__/"):
return self.get_response(request)

toolbar = DebugToolbar(request, self.get_response)
Expand All @@ -67,6 +61,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 @@ -75,8 +77,12 @@ def __call__(self, request):
getattr(response, "streaming", False),
"gzip" in content_encoding,
content_type not in _HTML_TYPES,
request.is_ajax(),
)
):
# If a AJAX or JSON request, render the toolbar for the history.
if request.is_ajax() or content_type == "application/json":
toolbar.render_toolbar()
return response

# Insert the toolbar in the response.
Expand All @@ -85,15 +91,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 "Content-Length" in response:
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
41 changes: 41 additions & 0 deletions debug_toolbar/panels/history/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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)

@staticmethod
def make_hash(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
87 changes: 87 additions & 0 deletions debug_toolbar/panels/history/panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import json
import logging
import sys
from collections import OrderedDict

from django.conf import settings
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.panels import Panel
from debug_toolbar.panels.history import views
from debug_toolbar.panels.history.forms import HistoryStoreForm

logger = logging.getLogger(__name__)


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"),
url(r"^history_refresh/$", views.history_refresh, name="history_refresh"),
]

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

def generate_stats(self, request, response):
if request.method == "GET":
data = request.GET.copy()
else:
data = request.POST.copy()
# GraphQL tends to not be populated in POST. If the request seems
# empty, check if it's a JSON request.
if not data and request.META.get("CONTENT_TYPE") == "application/json":
# Python <= 3.5's json.loads expects a string.
data = json.loads(
request.body
if sys.version_info[:2] > (3, 5)
else request.body.decode(request.encoding or settings.DEFAULT_CHARSET)
)
self.record_stats(
{
"request_url": request.get_full_path(),
"request_method": request.method,
"data": data,
"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,
"refresh_form": HistoryStoreForm(
initial={"store_id": self.toolbar.store_id}
),
},
)
62 changes: 62 additions & 0 deletions debug_toolbar/panels/history/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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")


@csrf_exempt
@require_show_toolbar
def history_refresh(request):
"""Returns the refreshed list of table rows for the History Panel."""
form = HistoryStoreForm(request.POST or None)

if form.is_valid():
requests = []
for id, toolbar in reversed(DebugToolbar._store.items()):
requests.append(
{
"id": id,
"content": render_to_string(
"debug_toolbar/panels/history_tr.html",
{
"id": id,
"store_context": {
"toolbar": toolbar,
"form": HistoryStoreForm(initial={"store_id": id}),
},
},
),
}
)

return JsonResponse({"requests": requests})
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
3 changes: 2 additions & 1 deletion debug_toolbar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"INSERT_BEFORE": "</body>",
"RENDER_PANELS": None,
"RESULTS_CACHE_SIZE": 10,
"RESULTS_CACHE_SIZE": 25,
"ROOT_TAG_EXTRA_ATTRS": "",
"SHOW_COLLAPSED": False,
"SHOW_TOOLBAR_CALLBACK": "debug_toolbar.middleware.show_toolbar",
Expand Down Expand Up @@ -53,6 +53,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
54 changes: 54 additions & 0 deletions debug_toolbar/static/debug_toolbar/js/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,60 @@ const djdt = {
});
});

// Used by the history panel
$$.on(djDebug, 'click', '.switchHistory', function(event) {
event.preventDefault();
const ajax_data = {};
const newStoreId = this.dataset.storeId;
const form = this.closest('form');
const tbody = this.closest('tbody');

ajax_data.url = this.getAttribute('formaction');

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

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

ajax(ajax_data.url, ajax_data).then(function(data) {
djDebug.setAttribute('data-store-id', newStoreId);
Object.keys(data).map(function (panelId) {
if (djDebug.querySelector('#'+panelId)) {
djDebug.querySelector('#'+panelId).outerHTML = data[panelId].content;
djDebug.querySelector('.djdt-'+panelId).outerHTML = data[panelId].button;
}
});
});
});

// Used by the history panel
$$.on(djDebug, 'click', '.refreshHistory', function(event) {
event.preventDefault();
const ajax_data = {};
const form = this.closest('form');
const container = djDebug.querySelector('#djdtHistoryRequests');

ajax_data.url = this.getAttribute('formaction');

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

ajax(ajax_data.url, ajax_data).then(function(data) {
if (data.requests.constructor === Array) {
data.requests.map(function(request) {
if (!container.querySelector('[data-store-id="'+request.id+'"]')) {
container.innerHTML = request.content + container.innerHTML;
}
});
}
});
});

// Used by the cache, profiling and SQL panels
$$.on(djDebug, 'click', 'a.djToggleSwitch', function(event) {
event.preventDefault();
Expand Down
Loading