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

Serialize the panels #1826

Open
wants to merge 9 commits into
base: serializable
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -12,3 +12,4 @@ htmlcov
.tox
geckodriver.log
coverage.xml
venv
26 changes: 23 additions & 3 deletions debug_toolbar/panels/__init__.py
@@ -1,4 +1,5 @@
from django.template.loader import render_to_string
from django.utils.functional import classproperty

from debug_toolbar import settings as dt_settings
from debug_toolbar.utils import get_name_from_obj
Expand All @@ -12,15 +13,22 @@ class Panel:
def __init__(self, toolbar, get_response):
self.toolbar = toolbar
self.get_response = get_response
self.from_store = False

# Private panel properties

@property
def panel_id(self):
return self.__class__.__name__
@classproperty
def panel_id(cls):
return cls.__name__

@property
def enabled(self) -> bool:
if self.from_store:
# If the toolbar was loaded from the store the existence of
# recorded data indicates whether it was enabled or not.
# We can't use the remainder of the logic since we don't have
# a request to work off of.
return bool(self.get_stats())
# The user's cookies should override the default value
cookie_value = self.toolbar.request.COOKIES.get("djdt" + self.panel_id)
if cookie_value is not None:
Expand Down Expand Up @@ -168,6 +176,9 @@ def record_stats(self, stats):
Each call to ``record_stats`` updates the statistics dictionary.
"""
self.toolbar.stats.setdefault(self.panel_id, {}).update(stats)
self.toolbar.store.save_panel(
self.toolbar.request_id, self.panel_id, self.toolbar.stats[self.panel_id]
)

def get_stats(self):
"""
Expand Down Expand Up @@ -251,6 +262,15 @@ def generate_server_timing(self, request, response):
Does not return a value.
"""

def load_stats_from_store(self, data):
"""
Instantiate the panel from serialized data.

Return the panel instance.
"""
self.toolbar.stats.setdefault(self.panel_id, {}).update(data)
self.from_store = True

@classmethod
def run_checks(cls):
"""
Expand Down
2 changes: 1 addition & 1 deletion debug_toolbar/panels/history/__init__.py
@@ -1,3 +1,3 @@
from debug_toolbar.panels.history.panel import HistoryPanel

__all__ = ["HistoryPanel"]
__all__ = [HistoryPanel.panel_id]
4 changes: 2 additions & 2 deletions debug_toolbar/panels/history/forms.py
Expand Up @@ -5,8 +5,8 @@ class HistoryStoreForm(forms.Form):
"""
Validate params

store_id: The key for the store instance to be fetched.
request_id: The key for the store instance to be fetched.
"""

store_id = forms.CharField(widget=forms.HiddenInput())
request_id = forms.CharField(widget=forms.HiddenInput())
exclude_history = forms.BooleanField(widget=forms.HiddenInput(), required=False)
21 changes: 10 additions & 11 deletions debug_toolbar/panels/history/panel.py
Expand Up @@ -23,9 +23,9 @@ class HistoryPanel(Panel):
def get_headers(self, request):
headers = super().get_headers(request)
observe_request = self.toolbar.get_observe_request()
store_id = self.toolbar.store_id
if store_id and observe_request(request):
headers["djdt-store-id"] = store_id
request_id = self.toolbar.request_id
if request_id and observe_request(request):
headers["djdt-request-id"] = request_id
return headers

@property
Expand Down Expand Up @@ -86,23 +86,22 @@ def content(self):

Fetch every store for the toolbar and include it in the template.
"""
stores = {}
for id, toolbar in reversed(self.toolbar._store.items()):
stores[id] = {
"toolbar": toolbar,
toolbar_history = {}
for request_id in reversed(self.toolbar.store.request_ids()):
toolbar_history[request_id] = {
"form": HistoryStoreForm(
initial={"store_id": id, "exclude_history": True}
initial={"request_id": request_id, "exclude_history": True}
),
}

return render_to_string(
self.template,
{
"current_store_id": self.toolbar.store_id,
"stores": stores,
"current_request_id": self.toolbar.request_id,
"toolbar_history": toolbar_history,
"refresh_form": HistoryStoreForm(
initial={
"store_id": self.toolbar.store_id,
"request_id": self.toolbar.request_id,
"exclude_history": True,
}
),
Expand Down
16 changes: 9 additions & 7 deletions debug_toolbar/panels/history/views.py
Expand Up @@ -3,6 +3,7 @@

from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar
from debug_toolbar.panels.history.forms import HistoryStoreForm
from debug_toolbar.store import get_store
from debug_toolbar.toolbar import DebugToolbar


Expand All @@ -13,12 +14,12 @@ def history_sidebar(request):
form = HistoryStoreForm(request.GET)

if form.is_valid():
store_id = form.cleaned_data["store_id"]
toolbar = DebugToolbar.fetch(store_id)
request_id = form.cleaned_data["request_id"]
toolbar = DebugToolbar.fetch(request_id)
exclude_history = form.cleaned_data["exclude_history"]
context = {}
if toolbar is None:
# When the store_id has been popped already due to
# When the request_id has been popped already due to
# RESULTS_CACHE_SIZE
return JsonResponse(context)
for panel in toolbar.panels:
Expand Down Expand Up @@ -46,19 +47,20 @@ def history_refresh(request):
if form.is_valid():
requests = []
# Convert to list to handle mutations happening in parallel
for id, toolbar in list(DebugToolbar._store.items()):
for request_id in get_store().request_ids():
toolbar = DebugToolbar.fetch(request_id)
requests.append(
{
"id": id,
"id": request_id,
"content": render_to_string(
"debug_toolbar/panels/history_tr.html",
{
"id": id,
"id": request_id,
"store_context": {
"toolbar": toolbar,
"form": HistoryStoreForm(
initial={
"store_id": id,
"request_id": request_id,
"exclude_history": True,
}
),
Expand Down
10 changes: 9 additions & 1 deletion debug_toolbar/panels/settings.py
@@ -1,4 +1,5 @@
from django.conf import settings
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _
from django.views.debug import get_default_exception_reporter_filter

Expand All @@ -20,4 +21,11 @@ def title(self):
return _("Settings from %s") % settings.SETTINGS_MODULE

def generate_stats(self, request, response):
self.record_stats({"settings": dict(sorted(get_safe_settings().items()))})
self.record_stats(
{
"settings": {
key: force_str(value)
for key, value in sorted(get_safe_settings().items())
}
}
)
2 changes: 1 addition & 1 deletion debug_toolbar/panels/sql/__init__.py
@@ -1,3 +1,3 @@
from debug_toolbar.panels.sql.panel import SQLPanel

__all__ = ["SQLPanel"]
__all__ = [SQLPanel.panel_id]
98 changes: 87 additions & 11 deletions debug_toolbar/panels/sql/forms.py
Expand Up @@ -4,25 +4,22 @@
from django.core.exceptions import ValidationError
from django.db import connections
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from debug_toolbar.panels.sql.utils import reformat_sql
from debug_toolbar.toolbar import DebugToolbar


class SQLSelectForm(forms.Form):
"""
Validate params

sql: The sql statement with interpolated params
raw_sql: The sql statement with placeholders
params: JSON encoded parameter values
duration: time for SQL to execute passed in from toolbar just for redisplay
request_id: The identifier for the request
query_id: The identifier for the query
"""

sql = forms.CharField()
raw_sql = forms.CharField()
params = forms.CharField()
alias = forms.CharField(required=False, initial="default")
duration = forms.FloatField()
request_id = forms.CharField()
djdt_query_id = forms.CharField()

def clean_raw_sql(self):
value = self.cleaned_data["raw_sql"]
Expand All @@ -48,12 +45,91 @@ def clean_alias(self):

return value

def clean(self):
from debug_toolbar.panels.sql import SQLPanel

cleaned_data = super().clean()
toolbar = DebugToolbar.fetch(
self.cleaned_data["request_id"], panel_id=SQLPanel.panel_id
)
if toolbar is None:
raise ValidationError(_("Data for this panel isn't available anymore."))

panel = toolbar.get_panel_by_id(SQLPanel.panel_id)
# Find the query for this form submission
query = None
for q in panel.get_stats()["queries"]:
if q["djdt_query_id"] != self.cleaned_data["djdt_query_id"]:
continue
else:
query = q
break
if not query:
raise ValidationError(_("Invalid query id."))
cleaned_data["query"] = query
return cleaned_data

def select(self):
query = self.cleaned_data["query"]
sql = query["raw_sql"]
params = json.loads(query["params"])
with self.cursor as cursor:
cursor.execute(sql, params)
headers = [d[0] for d in cursor.description]
result = cursor.fetchall()
return result, headers

def explain(self):
query = self.cleaned_data["query"]
sql = query["raw_sql"]
params = json.loads(query["params"])
vendor = query["vendor"]
with self.cursor as cursor:
if vendor == "sqlite":
# SQLite's EXPLAIN dumps the low-level opcodes generated for a query;
# EXPLAIN QUERY PLAN dumps a more human-readable summary
# See https://www.sqlite.org/lang_explain.html for details
cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params)
elif vendor == "postgresql":
cursor.execute(f"EXPLAIN ANALYZE {sql}", params)
else:
cursor.execute(f"EXPLAIN {sql}", params)
headers = [d[0] for d in cursor.description]
result = cursor.fetchall()
return result, headers

def profile(self):
query = self.cleaned_data["query"]
sql = query["raw_sql"]
params = json.loads(query["params"])
with self.cursor as cursor:
cursor.execute("SET PROFILING=1") # Enable profiling
cursor.execute(sql, params) # Execute SELECT
cursor.execute("SET PROFILING=0") # Disable profiling
# The Query ID should always be 1 here but I'll subselect to get
# the last one just in case...
cursor.execute(
"""
SELECT *
FROM information_schema.profiling
WHERE query_id = (
SELECT query_id
FROM information_schema.profiling
ORDER BY query_id DESC
LIMIT 1
)
"""
)
headers = [d[0] for d in cursor.description]
result = cursor.fetchall()
return result, headers

def reformat_sql(self):
return reformat_sql(self.cleaned_data["sql"], with_toggle=False)
return reformat_sql(self.cleaned_data["query"]["sql"], with_toggle=False)

@property
def connection(self):
return connections[self.cleaned_data["alias"]]
return connections[self.cleaned_data["query"]["alias"]]

@cached_property
def cursor(self):
Expand Down