diff --git a/debug_toolbar/decorators.py b/debug_toolbar/decorators.py index 787282706..61e46490d 100644 --- a/debug_toolbar/decorators.py +++ b/debug_toolbar/decorators.py @@ -1,5 +1,6 @@ import functools +from asgiref.sync import iscoroutinefunction from django.http import Http404 from django.utils.translation import get_language, override as language_override @@ -7,15 +8,30 @@ def require_show_toolbar(view): - @functools.wraps(view) - def inner(request, *args, **kwargs): - from debug_toolbar.middleware import get_show_toolbar + """ + Async compatible decorator to restrict access to a view + based on the Debug Toolbar's visibility settings. + """ + from debug_toolbar.middleware import get_show_toolbar + + if iscoroutinefunction(view): - show_toolbar = get_show_toolbar() - if not show_toolbar(request): - raise Http404 + @functools.wraps(view) + async def inner(request, *args, **kwargs): + show_toolbar = get_show_toolbar(async_mode=True) + if not await show_toolbar(request): + raise Http404 - return view(request, *args, **kwargs) + return await view(request, *args, **kwargs) + else: + + @functools.wraps(view) + def inner(request, *args, **kwargs): + show_toolbar = get_show_toolbar(async_mode=False) + if not show_toolbar(request): + raise Http404 + + return view(request, *args, **kwargs) return inner diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index 9986d9106..598ff3eef 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -6,7 +6,12 @@ import socket from functools import cache -from asgiref.sync import iscoroutinefunction, markcoroutinefunction +from asgiref.sync import ( + async_to_sync, + iscoroutinefunction, + markcoroutinefunction, + sync_to_async, +) from django.conf import settings from django.utils.module_loading import import_string @@ -47,7 +52,12 @@ def show_toolbar(request): @cache -def get_show_toolbar(): +def show_toolbar_func_or_path(): + """ + Fetch the show toolbar callback from settings + + Cached to avoid importing multiple times. + """ # If SHOW_TOOLBAR_CALLBACK is a string, which is the recommended # setup, resolve it to the corresponding callable. func_or_path = dt_settings.get_config()["SHOW_TOOLBAR_CALLBACK"] @@ -57,6 +67,23 @@ def get_show_toolbar(): return func_or_path +def get_show_toolbar(async_mode): + """ + Get the callback function to show the toolbar. + + Will wrap the function with sync_to_async or + async_to_sync depending on the status of async_mode + and whether the underlying function is a coroutine. + """ + show_toolbar = show_toolbar_func_or_path() + is_coroutine = iscoroutinefunction(show_toolbar) + if is_coroutine and not async_mode: + show_toolbar = async_to_sync(show_toolbar) + elif not is_coroutine and async_mode: + show_toolbar = sync_to_async(show_toolbar) + return show_toolbar + + class DebugToolbarMiddleware: """ Middleware to set up Debug Toolbar on incoming request and render toolbar @@ -82,7 +109,8 @@ def __call__(self, request): if self.async_mode: return self.__acall__(request) # Decide whether the toolbar is active for this request. - show_toolbar = get_show_toolbar() + show_toolbar = get_show_toolbar(async_mode=self.async_mode) + if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request): return self.get_response(request) toolbar = DebugToolbar(request, self.get_response) @@ -103,8 +131,9 @@ def __call__(self, request): async def __acall__(self, request): # Decide whether the toolbar is active for this request. - show_toolbar = get_show_toolbar() - if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request): + show_toolbar = get_show_toolbar(async_mode=self.async_mode) + + if not await show_toolbar(request) or DebugToolbar.is_toolbar_request(request): response = await self.get_response(request) return response diff --git a/docs/changes.rst b/docs/changes.rst index 9e09fd511..341f6e0b8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,9 @@ Pending * Added Django 5.2 to the tox matrix. * Updated package metadata to include well-known labels. * Added resources section to the documentation. +* Wrap ``SHOW_TOOLBAR_CALLBACK`` function with ``sync_to_async`` + or ``async_to_sync`` to allow sync/async compatibility. +* Make ``require_toolbar`` decorator compatible to async views. 5.0.1 (2025-01-13) ------------------ diff --git a/docs/panels.rst b/docs/panels.rst index 7892dcf94..be481fb6e 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -321,7 +321,7 @@ Panels can ship their own templates, static files and views. Any views defined for the third-party panel use the following decorators: - ``debug_toolbar.decorators.require_show_toolbar`` - Prevents unauthorized - access to the view. + access to the view. This decorator is compatible with async views. - ``debug_toolbar.decorators.render_with_toolbar_language`` - Supports internationalization for any content rendered by the view. This will render the response with the :ref:`TOOLBAR_LANGUAGE ` rather than diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 5e7c8523b..9840a6390 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,10 +1,10 @@ from unittest.mock import patch -from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.http import Http404, HttpResponse +from django.test import AsyncRequestFactory, RequestFactory, TestCase from django.test.utils import override_settings -from debug_toolbar.decorators import render_with_toolbar_language +from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar @render_with_toolbar_language @@ -12,6 +12,46 @@ def stub_view(request): return HttpResponse(200) +@require_show_toolbar +def stub_require_toolbar_view(request): + return HttpResponse(200) + + +@require_show_toolbar +async def stub_require_toolbar_async_view(request): + return HttpResponse(200) + + +class TestRequireToolbar(TestCase): + """ + Tests require_toolbar functionality and async compatibility. + """ + + def setUp(self): + self.factory = RequestFactory() + self.async_factory = AsyncRequestFactory() + + @override_settings(DEBUG=True) + def test_require_toolbar_debug_true(self): + response = stub_require_toolbar_view(self.factory.get("/")) + self.assertEqual(response.status_code, 200) + + def test_require_toolbar_debug_false(self): + with self.assertRaises(Http404): + stub_require_toolbar_view(self.factory.get("/")) + + # Following tests additionally tests async compatibility + # of require_toolbar decorator + @override_settings(DEBUG=True) + async def test_require_toolbar_async_debug_true(self): + response = await stub_require_toolbar_async_view(self.async_factory.get("/")) + self.assertEqual(response.status_code, 200) + + async def test_require_toolbar_async_debug_false(self): + with self.assertRaises(Http404): + await stub_require_toolbar_async_view(self.async_factory.get("/")) + + @override_settings(DEBUG=True, LANGUAGE_CODE="fr") class RenderWithToolbarLanguageTestCase(TestCase): @override_settings(DEBUG_TOOLBAR_CONFIG={"TOOLBAR_LANGUAGE": "de"}) diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 000000000..56081ce56 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,93 @@ +import asyncio +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.http import HttpResponse +from django.test import AsyncRequestFactory, RequestFactory, TestCase, override_settings + +from debug_toolbar.middleware import DebugToolbarMiddleware + + +def show_toolbar_if_staff(request): + # Hit the database, but always return True + return User.objects.exists() or True + + +async def ashow_toolbar_if_staff(request): + # Hit the database, but always return True + has_users = await User.objects.afirst() + return has_users or True + + +class MiddlewareSyncAsyncCompatibilityTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.async_factory = AsyncRequestFactory() + + @override_settings(DEBUG=True) + def test_sync_mode(self): + """ + test middleware switches to sync (__call__) based on get_response type + """ + + request = self.factory.get("/") + middleware = DebugToolbarMiddleware( + lambda x: HttpResponse("Test app") + ) + + self.assertFalse(asyncio.iscoroutinefunction(middleware)) + + response = middleware(request) + self.assertEqual(response.status_code, 200) + self.assertIn(b"djdt", response.content) + + @override_settings(DEBUG=True) + async def test_async_mode(self): + """ + test middleware switches to async (__acall__) based on get_response type + and returns a coroutine + """ + + async def get_response(request): + return HttpResponse("Test app") + + middleware = DebugToolbarMiddleware(get_response) + request = self.async_factory.get("/") + + self.assertTrue(asyncio.iscoroutinefunction(middleware)) + + response = await middleware(request) + self.assertEqual(response.status_code, 200) + self.assertIn(b"djdt", response.content) + + @override_settings(DEBUG=True) + @patch( + "debug_toolbar.middleware.show_toolbar_func_or_path", + return_value=ashow_toolbar_if_staff, + ) + def test_async_show_toolbar_callback_sync_middleware(self, mocked_show): + def get_response(request): + return HttpResponse("Hello world") + + middleware = DebugToolbarMiddleware(get_response) + + request = self.factory.get("/") + response = middleware(request) + self.assertEqual(response.status_code, 200) + self.assertIn(b"djdt", response.content) + + @override_settings(DEBUG=True) + @patch( + "debug_toolbar.middleware.show_toolbar_func_or_path", + return_value=show_toolbar_if_staff, + ) + async def test_sync_show_toolbar_callback_async_middleware(self, mocked_show): + async def get_response(request): + return HttpResponse("Hello world") + + middleware = DebugToolbarMiddleware(get_response) + + request = self.async_factory.get("/") + response = await middleware(request) + self.assertEqual(response.status_code, 200) + self.assertIn(b"djdt", response.content) diff --git a/tests/test_middleware_compatibility.py b/tests/test_middleware_compatibility.py deleted file mode 100644 index 1337864b1..000000000 --- a/tests/test_middleware_compatibility.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio - -from django.http import HttpResponse -from django.test import AsyncRequestFactory, RequestFactory, TestCase, override_settings - -from debug_toolbar.middleware import DebugToolbarMiddleware - - -class MiddlewareSyncAsyncCompatibilityTestCase(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.async_factory = AsyncRequestFactory() - - @override_settings(DEBUG=True) - def test_sync_mode(self): - """ - test middleware switches to sync (__call__) based on get_response type - """ - - request = self.factory.get("/") - middleware = DebugToolbarMiddleware( - lambda x: HttpResponse("Django debug toolbar") - ) - - self.assertFalse(asyncio.iscoroutinefunction(middleware)) - - response = middleware(request) - self.assertEqual(response.status_code, 200) - - @override_settings(DEBUG=True) - async def test_async_mode(self): - """ - test middleware switches to async (__acall__) based on get_response type - and returns a coroutine - """ - - async def get_response(request): - return HttpResponse("Django debug toolbar") - - middleware = DebugToolbarMiddleware(get_response) - request = self.async_factory.get("/") - - self.assertTrue(asyncio.iscoroutinefunction(middleware)) - - response = await middleware(request) - self.assertEqual(response.status_code, 200)