Add Tasks panel showing tasks queued during the request#2407
Conversation
Adds a new panel using Django's built-in tasks framework (django.tasks, added in Django 6.0). Shows task name, queue, backend, priority, run_after, status, and arguments for each task queued during the request. On Django < 6.0, the panel explains that upgrading is required instead of showing task data. Closes django-commons#2204
|
Hi @onxxdatas Thanks for your contribution! Can you post a screenshot of how the panel looks when some tasks have been enqueued? That would be very helpful. |
|
And also, did you see #2235 ? How does this change compare to the panel proposed in the other PR? |
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||
|
@matthiask Here's the panel with tasks enqueued. I also added a small demo page |
I saw two structural differences in #2235 compared to mine. First of all it instruments by monkeypatching Task.enqueue directly on the class (Task.enqueue = wrapped_enqueue). Since the panel is per request and is_async = True, this isn't safe under concurrent&async requests. Two overlapping requests can race on the patch, with disable_instrumentation() potentially restoring the wrong 'original' and leaving tasks misattributed or the method unpatched afterward. Second one is that on django < 6.0, _tasks_available returns False and instrumentation just no ops silently so the panel still shows up but always displays zero tasks with no indication to the user that it's unsupported on their Django version. |
tim-schilling
left a comment
There was a problem hiding this comment.
I think this is a great start! I have a handful of small requests. I'd be happy to have this merged with those addressed (or resolving the conversations through discussion if you disagree).
One thing we probably do want to include eventually is the stacktrace from where the task was queued, the same way we can determine where a cache call or SQL query was ran. I would suggest looking at the CachePanel for inspiration as it should be simpler. If you want to add that in a follow-up PR that's fine too.
| def nav_subtitle(self): | ||
| if task_enqueued is None: | ||
| return _("Requires Django 6.0+") | ||
| tasks = self.get_stats().get("tasks", []) |
There was a problem hiding this comment.
| tasks = self.get_stats().get("tasks", []) | |
| tasks = self.get_stats()["tasks"] |
| "run_after": task.run_after, | ||
| "takes_context": task.takes_context, | ||
| "args": task_result.args, | ||
| "kwargs": task_result.kwargs, |
There was a problem hiding this comment.
Should we use toolbar.utils.sanitize_and_sort_request_vars here?
| task = None | ||
|
|
||
|
|
||
| if task is not None: |
There was a problem hiding this comment.
I feel like comparing on the Django version will be easier for us to keep up dated as versions pass rather than checking the imported variable. It may be better to just do one big if statement for tasks_view and example_task as well then.
| try: | ||
| from django.tasks import task | ||
| except ImportError: | ||
| task = None |
There was a problem hiding this comment.
Let's extract this into a compat.py file and when it doesn't exist, create a pass-through decorator. That way we can can use skipUnless with the version which is more typical.
| self.assertTrue(stats["tasks_available"]) | ||
| self.assertEqual(len(stats["tasks"]), 1) | ||
|
|
||
| recorded = stats["tasks"][0] | ||
| self.assertEqual(recorded["name"], f"{__name__}.sample_task") | ||
| self.assertEqual(list(recorded["args"]), [2]) | ||
| self.assertEqual(dict(recorded["kwargs"]), {"y": 3}) | ||
| self.assertEqual(recorded["queue_name"], "default") | ||
| self.assertEqual(recorded["backend"], "default") |
There was a problem hiding this comment.
Nitpick: I think we may want to compare the stats dictionary as a whole. For example self.assertEqual(stats, { ... }) instead of the individual assertions.
| On Django < 6.0, django.tasks doesn't exist, so the panel should | ||
| explain that instead of showing task data. | ||
| """ | ||
| with mock.patch("debug_toolbar.panels.tasks.task_enqueued", None): |
There was a problem hiding this comment.
Rather than patching this, we could do @skipUnless(django.VERSION < (6, 0))
|
@tim-schilling Thanks for the thorough review👍🏼 I've pushed a commit addressing all 6 points. |
|
@tim-schilling The failures only happen on the djmain test matrix, on Python 3.13/3.14 & dj52 and dj60 (the two released Django versions) both pass. The cause is django-csp's CSPMiddleware, which still imports MiddlewareMixin from this deprecated location: MiddlewareMixin from django.utils.deprecation. Django's dev branch just started emitting that warning, and since the test runner treats warnings as errors (-b flag), it fails 4 CSP tests. |
codingjoe
left a comment
There was a problem hiding this comment.
Nice, I am looking forward to this one 👍
|
|
||
| # Implement the Panel API | ||
|
|
||
| nav_title = _("Tasks") |
There was a problem hiding this comment.
Do we cap, single words? I'd usually like to use capfirst or title in templates and keep translation placeholders ambiguous.
There was a problem hiding this comment.
I've updated the translation string to _("tasks"). I left the template unchanged since that would affect all panel titles.
| if task_enqueued is None: | ||
| return _("Requires Django 6.0+") |
There was a problem hiding this comment.
That's an interesting solution. A django.__VERSION__ condition might be easier to find when we remove Django 5.2 support.
There was a problem hiding this comment.
Yep, using django.VERSION is preferrable since those blocks can automatically be dropped using django-upgrade later.
There was a problem hiding this comment.
updated to use django.VERSION for the compatibility check
There was a problem hiding this comment.
| if task_enqueued is None: | |
| return _("Requires Django 6.0+") | |
| if django.VERSION < (6, 0): | |
| return _("Requires Django 6.0+") |
@onxxdatas this is what they are suggesting.
There was a problem hiding this comment.
Yeah-yeah, I just pushed all my changes
| { | ||
| "id": task_result.id, | ||
| "name": task.module_path, | ||
| "queue_name": task.queue_name, | ||
| "priority": task.priority, | ||
| "backend": task_result.backend, | ||
| "run_after": task.run_after, | ||
| "takes_context": task.takes_context, | ||
| "args": sanitize_and_sort_request_vars(task_result.args), | ||
| "kwargs": sanitize_and_sort_request_vars(task_result.kwargs), | ||
| "status": task_result.status, | ||
| } |
There was a problem hiding this comment.
Why do we need to come up with our custom data structure here? TaskResult holds all the important information.
There was a problem hiding this comment.
It can also fairly easily be JSON serialized, if that is needed.
There was a problem hiding this comment.
removed the custom dictionary and now we rely on TaskResult directly.
There was a problem hiding this comment.
@codingjoe are you suggesting we shouldn't be appending a custom dictionary to tasks here?
There was a problem hiding this comment.
I was suggesting to just pass the TaskResult instance to the template. I don't see why we need this mapping here. In other words:
self.task: lists[TaskResult]There was a problem hiding this comment.
do you have any other change request based on my last commit?
| "name": task.module_path, | ||
| "queue_name": task.queue_name, | ||
| "priority": task.priority, | ||
| "backend": task_result.backend, |
There was a problem hiding this comment.
The actual backend class is probably less interesting. The alias is usually probably the imporant information.
There was a problem hiding this comment.
Based on my decision, I updated the template to show backend alias from Tasks
There was a problem hiding this comment.
We've had people want to know what the backend is for caches as well. Plus using this in different environments may mean using different backends (Immediate vs DB vs something else)
There was a problem hiding this comment.
Good point. I focused on the alias because it identifies the configured task backend, but I agree the actual backend implementation can be valuable when debugging differences between environments. What do you think about showing both alias and backend class at once?
There was a problem hiding this comment.
I am just realizing they are one and the same. Poor naming on Django's site.
So task_result.backend is the alias.
| <table> | ||
| <thead> | ||
| <tr> | ||
| <th>{% translate "Name" %}</th> |
There was a problem hiding this comment.
Just a general thought:
| <th>{% translate "Name" %}</th> | |
| <th>{% translate "name"|title %}</th> |
There was a problem hiding this comment.
By the way, I lowercased the other translation strings too
| * Added a Tasks panel that shows tasks queued during the request via | ||
| Django's built-in tasks framework (``django.tasks``, Django 6.0+). On | ||
| older versions of Django, the panel explains that upgrading is required. |
There was a problem hiding this comment.
Maybe a merge error, but there seems to be duplication.
| added in that release. On earlier versions of Django, the panel | ||
| explains that upgrading is required instead of showing task data. |
There was a problem hiding this comment.
Maybe a little verbose. Most people will figure out that Django <6.0 doesn't have a task framework. The error message is good, but IMHO it doesn't need to be documented.
There was a problem hiding this comment.
Now it just says "Requires Django 6.0+."
Yep, that's fixed now. |
codingjoe
left a comment
There was a problem hiding this comment.
Almost! Sorry, I found just a few more small things. All nit picks, but when in rome 🤷
| <thead> | ||
| <tr> | ||
| <th>{% translate "name"|title %}</th> | ||
| <th>{% translate "queue"|title %}</th> |
There was a problem hiding this comment.
ambiguous, could be verb to queue
| <th>{% translate "queue"|title %}</th> | |
| <th>{% translate "queue"|title context "noun" %}</th> |
| <table> | ||
| <thead> | ||
| <tr> | ||
| <th>{% translate "name"|title %}</th> |
There was a problem hiding this comment.
ambiguous, could be verb to name
| <th>{% translate "name"|title %}</th> | |
| <th>{% translate "name"|title context "noun" %}</th> |
| @@ -0,0 +1,40 @@ | |||
| {% load i18n %} | |||
| {% if not tasks_available %} | |||
There was a problem hiding this comment.
I'd like to avoid negations. You can do if task .. elif task_available .. else.
That being said, the task_available could be replaced with a Django version check too. Again, easier to remove once Django 5.2 goes the way of the dodo.
| super().__init__(*args, **kwargs) | ||
| self.tasks = [] | ||
|
|
||
| # Implement the Panel API |
There was a problem hiding this comment.
If it doesn't spark joy, it can go. — Marie Kondo
| # Implement the Panel API |
There was a problem hiding this comment.
Removed. Didn't spark joy😄
Also fixed the "name"/"queue" ambiguity and the negation in the template, all pushed.
…able / else) - Disambiguate 'name'/'queue' translations with context 'noun' - Use django.VERSION instead of task_enqueued is None - Drop the 'Implement the Panel API' comment - Keep task stats as a dict mirroring TaskResult's own field names (raw TaskResult isn't JSON-serializable - Task.func breaks the history panel/async request storage round-trip) - Revert nav_title/title to 'Tasks' (capitalized), matching every other panel, since panel_button.html has no capfirst/title filter
| <table> | ||
| <thead> | ||
| <tr> | ||
| <th>{% translate "name"|title context "noun" %}</th> |
There was a problem hiding this comment.
Name may be ambiguous here. Path or Python-path might be more fitting.


Description
Adds a new panel using Django's built-in tasks framework (django.tasks, added in Django 6.0). Shows task name, queue, backend, priority, run_after, status, and arguments for each task queued during the request. On Django < 6.0, the panel explains that upgrading is required instead of showing task data..
Fixes #2204
Checklist:
docs/changes.rst.AI/LLM Usage