diff --git a/README.rst b/README.rst index 2ce1db4b7..fa12e35c1 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Here's a screenshot of the toolbar in action: In addition to the built-in panels, a number of third-party panels are contributed by the community. -The current stable version of the Debug Toolbar is 4.4.3. It works on +The current stable version of the Debug Toolbar is 4.4.4. It works on Django ≥ 4.2.0. The Debug Toolbar does not currently support `Django's asynchronous views diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py index 5ddb15d15..f5f18057b 100644 --- a/debug_toolbar/__init__.py +++ b/debug_toolbar/__init__.py @@ -4,7 +4,7 @@ # Do not use pkg_resources to find the version but set it here directly! # see issue #1446 -VERSION = "4.4.3" +VERSION = "4.4.4" # Code that discovers files or modules in INSTALLED_APPS imports this module. urls = "debug_toolbar.urls", APP_NAME diff --git a/debug_toolbar/panels/alerts.py b/debug_toolbar/panels/alerts.py index 27a7119ee..32c656dde 100644 --- a/debug_toolbar/panels/alerts.py +++ b/debug_toolbar/panels/alerts.py @@ -1,148 +1,152 @@ -from html.parser import HTMLParser - -from django.utils.translation import gettext_lazy as _ - -from debug_toolbar.panels import Panel - - -class FormParser(HTMLParser): - """ - HTML form parser, used to check for invalid configurations of forms that - take file inputs. - """ - - def __init__(self): - super().__init__() - self.in_form = False - self.current_form = {} - self.forms = [] - self.form_ids = [] - self.referenced_file_inputs = [] - - def handle_starttag(self, tag, attrs): - attrs = dict(attrs) - if tag == "form": - self.in_form = True - form_id = attrs.get("id") - if form_id: - self.form_ids.append(form_id) - self.current_form = { - "file_form": False, - "form_attrs": attrs, - "submit_element_attrs": [], - } - elif ( - self.in_form - and tag == "input" - and attrs.get("type") == "file" - and (not attrs.get("form") or attrs.get("form") == "") - ): - self.current_form["file_form"] = True - elif ( - self.in_form - and ( - (tag == "input" and attrs.get("type") in {"submit", "image"}) - or tag == "button" - ) - and (not attrs.get("form") or attrs.get("form") == "") - ): - self.current_form["submit_element_attrs"].append(attrs) - elif tag == "input" and attrs.get("form"): - self.referenced_file_inputs.append(attrs) - - def handle_endtag(self, tag): - if tag == "form" and self.in_form: - self.forms.append(self.current_form) - self.in_form = False - - -class AlertsPanel(Panel): - """ - A panel to alert users to issues. - """ - - messages = { - "form_id_missing_enctype": _( - 'Form with id "{form_id}" contains file input, but does not have the attribute enctype="multipart/form-data".' - ), - "form_missing_enctype": _( - 'Form contains file input, but does not have the attribute enctype="multipart/form-data".' - ), - "input_refs_form_missing_enctype": _( - 'Input element references form with id "{form_id}", but the form does not have the attribute enctype="multipart/form-data".' - ), - } - - title = _("Alerts") - - template = "debug_toolbar/panels/alerts.html" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.alerts = [] - - @property - def nav_subtitle(self): - alerts = self.get_stats()["alerts"] - if alerts: - alert_text = "alert" if len(alerts) == 1 else "alerts" - return f"{len(alerts)} {alert_text}" - else: - return "" - - def add_alert(self, alert): - self.alerts.append(alert) - - def check_invalid_file_form_configuration(self, html_content): - """ - Inspects HTML content for a form that includes a file input but does - not have the encoding type set to multipart/form-data, and warns the - user if so. - """ - parser = FormParser() - parser.feed(html_content) - - # Check for file inputs directly inside a form that do not reference - # any form through the form attribute - for form in parser.forms: - if ( - form["file_form"] - and form["form_attrs"].get("enctype") != "multipart/form-data" - and not any( - elem.get("formenctype") == "multipart/form-data" - for elem in form["submit_element_attrs"] - ) - ): - if form_id := form["form_attrs"].get("id"): - alert = self.messages["form_id_missing_enctype"].format( - form_id=form_id - ) - else: - alert = self.messages["form_missing_enctype"] - self.add_alert({"alert": alert}) - - # Check for file inputs that reference a form - form_attrs_by_id = { - form["form_attrs"].get("id"): form["form_attrs"] for form in parser.forms - } - - for attrs in parser.referenced_file_inputs: - form_id = attrs.get("form") - if form_id and attrs.get("type") == "file": - form_attrs = form_attrs_by_id.get(form_id) - if form_attrs and form_attrs.get("enctype") != "multipart/form-data": - alert = self.messages["input_refs_form_missing_enctype"].format( - form_id=form_id - ) - self.add_alert({"alert": alert}) - - return self.alerts - - def generate_stats(self, request, response): - html_content = response.content.decode(response.charset) - self.check_invalid_file_form_configuration(html_content) - - # Further alert checks can go here - - # Write all alerts to record_stats - self.record_stats({"alerts": self.alerts}) +from html.parser import HTMLParser + +from django.utils.translation import gettext_lazy as _ + +from debug_toolbar.panels import Panel + + +class FormParser(HTMLParser): + """ + HTML form parser, used to check for invalid configurations of forms that + take file inputs. + """ + + def __init__(self): + super().__init__() + self.in_form = False + self.current_form = {} + self.forms = [] + self.form_ids = [] + self.referenced_file_inputs = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.in_form = True + form_id = attrs.get("id") + if form_id: + self.form_ids.append(form_id) + self.current_form = { + "file_form": False, + "form_attrs": attrs, + "submit_element_attrs": [], + } + elif ( + self.in_form + and tag == "input" + and attrs.get("type") == "file" + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["file_form"] = True + elif ( + self.in_form + and ( + (tag == "input" and attrs.get("type") in {"submit", "image"}) + or tag == "button" + ) + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["submit_element_attrs"].append(attrs) + elif tag == "input" and attrs.get("form"): + self.referenced_file_inputs.append(attrs) + + def handle_endtag(self, tag): + if tag == "form" and self.in_form: + self.forms.append(self.current_form) + self.in_form = False + + +class AlertsPanel(Panel): + """ + A panel to alert users to issues. + """ + + messages = { + "form_id_missing_enctype": _( + 'Form with id "{form_id}" contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "form_missing_enctype": _( + 'Form contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "input_refs_form_missing_enctype": _( + 'Input element references form with id "{form_id}", but the form does not have the attribute enctype="multipart/form-data".' + ), + } + + title = _("Alerts") + + template = "debug_toolbar/panels/alerts.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.alerts = [] + + @property + def nav_subtitle(self): + alerts = self.get_stats()["alerts"] + if alerts: + alert_text = "alert" if len(alerts) == 1 else "alerts" + return f"{len(alerts)} {alert_text}" + else: + return "" + + def add_alert(self, alert): + self.alerts.append(alert) + + def check_invalid_file_form_configuration(self, html_content): + """ + Inspects HTML content for a form that includes a file input but does + not have the encoding type set to multipart/form-data, and warns the + user if so. + """ + parser = FormParser() + parser.feed(html_content) + + # Check for file inputs directly inside a form that do not reference + # any form through the form attribute + for form in parser.forms: + if ( + form["file_form"] + and form["form_attrs"].get("enctype") != "multipart/form-data" + and not any( + elem.get("formenctype") == "multipart/form-data" + for elem in form["submit_element_attrs"] + ) + ): + if form_id := form["form_attrs"].get("id"): + alert = self.messages["form_id_missing_enctype"].format( + form_id=form_id + ) + else: + alert = self.messages["form_missing_enctype"] + self.add_alert({"alert": alert}) + + # Check for file inputs that reference a form + form_attrs_by_id = { + form["form_attrs"].get("id"): form["form_attrs"] for form in parser.forms + } + + for attrs in parser.referenced_file_inputs: + form_id = attrs.get("form") + if form_id and attrs.get("type") == "file": + form_attrs = form_attrs_by_id.get(form_id) + if form_attrs and form_attrs.get("enctype") != "multipart/form-data": + alert = self.messages["input_refs_form_missing_enctype"].format( + form_id=form_id + ) + self.add_alert({"alert": alert}) + + return self.alerts + + def generate_stats(self, request, response): + # check if streaming response + if getattr(response, "streaming", True): + return + + html_content = response.content.decode(response.charset) + self.check_invalid_file_form_configuration(html_content) + + # Further alert checks can go here + + # Write all alerts to record_stats + self.record_stats({"alerts": self.alerts}) diff --git a/debug_toolbar/panels/templates/jinja2.py b/debug_toolbar/panels/templates/jinja2.py new file mode 100644 index 000000000..d343cb140 --- /dev/null +++ b/debug_toolbar/panels/templates/jinja2.py @@ -0,0 +1,23 @@ +import functools + +from django.template.backends.jinja2 import Template as JinjaTemplate +from django.template.context import make_context +from django.test.signals import template_rendered + + +def patch_jinja_render(): + orig_render = JinjaTemplate.render + + @functools.wraps(orig_render) + def wrapped_render(self, context=None, request=None): + # This patching of render only instruments the rendering + # of the immediate template. It won't include the parent template(s). + self.name = self.template.name + template_rendered.send( + sender=self, template=self, context=make_context(context, request) + ) + return orig_render(self, context, request) + + if JinjaTemplate.render != wrapped_render: + JinjaTemplate.original_render = JinjaTemplate.render + JinjaTemplate.render = wrapped_render diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 81d7e5fad..182f80aab 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -14,6 +14,7 @@ from debug_toolbar.panels import Panel from debug_toolbar.panels.sql.tracking import SQLQueryTriggered, allow_sql from debug_toolbar.panels.templates import views +from debug_toolbar.panels.templates.jinja2 import patch_jinja_render # Monkey-patch to enable the template_rendered signal. The receiver returns # immediately when the panel is disabled to keep the overhead small. @@ -25,6 +26,7 @@ Template.original_render = Template._render Template._render = instrumented_test_render +patch_jinja_render() # Monkey-patch to store items added by template context processors. The # overhead is sufficiently small to justify enabling it unconditionally. diff --git a/docs/changes.rst b/docs/changes.rst index 4d26be57f..539c9883c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,10 +4,18 @@ Change log Pending ------- +4.4.4 (2024-07-05) +------------------ + +* Added check for StreamingHttpResponse in alerts panel. +* Instrument the Django Jinja2 template backend. This only instruments + the immediate template that's rendered. It will not provide stats on + any parent templates. + 4.4.3 (2024-07-04) ------------------ -* Added alert panel with warning when form is using file fields +* Added alerts panel with warning when form is using file fields without proper encoding type. * Fixed overriding font-family for both light and dark themes. * Restored compatibility with ``iptools.IpRangeList``. diff --git a/docs/conf.py b/docs/conf.py index 5f69100f7..b155e44ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ copyright = copyright.format(datetime.date.today().year) # The full version, including alpha/beta/rc tags -release = "4.4.3" +release = "4.4.4" # -- General configuration --------------------------------------------------- diff --git a/example/settings.py b/example/settings.py index 1508b5a29..26b75fa5c 100644 --- a/example/settings.py +++ b/example/settings.py @@ -41,6 +41,12 @@ STATIC_URL = "/static/" TEMPLATES = [ + { + "NAME": "jinja2", + "BACKEND": "django.template.backends.jinja2.Jinja2", + "APP_DIRS": True, + "DIRS": [os.path.join(BASE_DIR, "example", "templates", "jinja2")], + }, { "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, @@ -54,7 +60,7 @@ "django.contrib.messages.context_processors.messages", ], }, - } + }, ] USE_TZ = True diff --git a/example/templates/index.html b/example/templates/index.html index 527f5d2a3..4b25aefca 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -9,6 +9,7 @@

Index of Tests

{% cache 10 index_cache %}