Skip to content
Draft
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
87 changes: 87 additions & 0 deletions debug_toolbar/panels/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from django.utils.translation import gettext_lazy as _, ngettext

from debug_toolbar.panels import Panel


class TasksPanel(Panel):
"""
Panel that displays Django tasks queued or executed during the
processing of the request.
"""

title = _("Tasks")
template = "debug_toolbar/panels/tasks.html"

is_async = True

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.queued_tasks = []

@property
def nav_subtitle(self):
num_tasks = self.get_stats()["total_tasks"]
return ngettext(
"%(num_tasks)d task enqueued",
"%(num_tasks)d tasks enqueued",
num_tasks,
) % {"num_tasks": num_tasks}

def generate_stats(self, request, response):
stats = {"tasks": self.queued_tasks, "total_tasks": len(self.queued_tasks)}

self.record_stats(stats)

def enable_instrumentation(self):
"""Hook into task system to collect queued tasks"""
try:
import django

if django.VERSION < (6, 0):
return
from django.tasks import Task

print("[TasksPanel] instrumentation enabled:", hasattr(Task, "enqueue"))

# Store original enqueue method
if hasattr(Task, "enqueue"):
self._original_enqueue = Task.enqueue

def wrapped_enqueue(task, *args, **kwargs):
result = self._original_enqueue(task, *args, **kwargs).return_value
self._record_task(task, args, kwargs, result)
return result

Task.enqueue = wrapped_enqueue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This task wrapping looks great. That seems to be the main bits needed to collect stats for the toolbar panel. Nice!

except (ImportError, AttributeError):
pass

def _record_task(self, task, args, kwargs, result):
"""Record a task that was queued"""
task_info = {
"name": getattr(task, "__name__", str(task)),
"args": repr(args) if args else "",
"kwargs": repr(kwargs) if kwargs else "",
}
self.queued_tasks.append(task_info)

def disable_instrumentation(self):
"""Restore original methods"""
try:
from django.tasks import Task

if hasattr(self, "_original_enqueue"):
Task.enqueue = self._original_enqueue
except (ImportError, AttributeError):
pass

def _check_tasks_available(self):
"""Check if Django tasks system is available"""
try:
import django

if django.VERSION < (6, 0):
return False
return True
except (ImportError, AttributeError):
return False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this to be used in the enable_instrumentation method? Seems like it would fit there. The import django can go at the top with the other imports and you can remove the exceptions for that. This project has django as a dependency so it's safe to assume Django is available.

1 change: 1 addition & 0 deletions debug_toolbar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def get_config():
"debug_toolbar.panels.cache.CachePanel",
"debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.community.CommunityPanel",
"debug_toolbar.panels.tasks.TasksPanel",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tim-schilling I wonder if it would make sense to check for the Django version here, and only include it if its on a Django version that supports the tasks?

"debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel",
]
Expand Down
14 changes: 14 additions & 0 deletions debug_toolbar/templates/debug_toolbar/panels/tasks.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<table>
<thead>
<tr>
<th>Task Info</th>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need to be wrapped for translation :-)

</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task }}</td>
</tr>
{% endfor %}
</tbody>
</table>
19 changes: 19 additions & 0 deletions example/async_/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
try:
from django.tasks import task
except ImportError:
# Define a fallback decorator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the cases where this could happen? I'm guessing just in older django versions?

Is a fallback decorator a common pattern in this case? I think if its an example meant to work with tasks it shouldn't need a fallback, but I'd be interested to hear more of your thought process. :-)

def task(func=None, **kwargs):
def decorator(f):
return f

return decorator if func is None else decorator(func)


@task
def send_welcome_message(message):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest setting up some tasks that follow the Django examples tasks closely:

https://docs.djangoproject.com/en/dev/topics/tasks/

That will help it feel a bit more 'real-world' and may inspire further ideas for functionality in the panel itself :-)

return f"Sent message: {message}"


@task
def generate_report(report_id):
return f"Report {report_id} generated"
8 changes: 8 additions & 0 deletions example/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio

import django
from asgiref.sync import sync_to_async
from django.contrib.auth.models import User
from django.http import JsonResponse
Expand All @@ -20,6 +21,13 @@ def jinja2_view(request):


async def async_home(request):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super familiar with this codebase, but when does this view get used?

I'd defer to @tim-schilling and others, but it may make sense to have a dedicated view for triggering off example tasks vs, say, triggering every time you visit an example home page. That could also be a bit more self-documenting and give a place to build off of for the future (like testing larger queues, reporting results on a page template, etc)

if django.VERSION >= (6, 0):
from .async_.tasks import generate_report, send_welcome_message

# Queue some tasks
send_welcome_message.enqueue(message="hi there")
generate_report.enqueue(report_id=456)

return await sync_to_async(render)(request, "index.html")


Expand Down
Loading