Skip to content
Merged
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
18 changes: 18 additions & 0 deletions docs/content/docs/guides/integrations/django.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ pip install taskito[django]

## Setup

Add the integration to `INSTALLED_APPS`. This is required for the admin
templates to load — Django's app-directories template loader only scans
installed apps — and for task autodiscovery:

```python
# settings.py
INSTALLED_APPS = [
# ...
"django.contrib.admin",
"taskito.contrib.django",
]
```

<Callout type="info" title="Template loading">
Keep the default `APP_DIRS = True` in your `TEMPLATES` setting (Django's
default) so the bundled admin templates are discovered.
</Callout>

### Option 1: register on the default admin site

In your project's `admin.py` or `urls.py`:
Expand Down
87 changes: 65 additions & 22 deletions py_src/taskito/contrib/django/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,41 @@
from django.contrib import admin
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
from django.urls import path
from django.urls import path, reverse
except ImportError as e:
raise ImportError(
"Django integration requires 'django'. Install with: pip install taskito[django]"
) from e


def _base_context(request: HttpRequest, site: Any, **extra: Any) -> dict[str, Any]:
"""Build the shared template context for every taskito admin view.

Resolves the nav URLs against the *current* admin site's namespace
(``site.name``) so the templates work on both the default admin site and a
custom :class:`TaskitoAdminSite`. ``job_detail_name`` is passed as a view
name (not a resolved URL) so templates can reverse it per-row with the job
id via ``{% url %}``.
"""
namespace = site.name
context: dict[str, Any] = {
**site.each_context(request),
"taskito_urls": {
"dashboard": reverse(f"{namespace}:taskito_dashboard"),
"jobs": reverse(f"{namespace}:taskito_jobs"),
"dead_letters": reverse(f"{namespace}:taskito_dead_letters"),
"job_detail_name": f"{namespace}:taskito_job_detail",
},
}
context.update(extra)
return context


def _dashboard_view(request: HttpRequest, site: Any) -> HttpResponse:
from taskito.contrib.django.settings import get_queue

queue = get_queue()
stats = queue.stats()
context = {**site.each_context(request), "stats": stats, "title": "Taskito Dashboard"}
context = _base_context(request, site, stats=queue.stats(), title="Taskito Dashboard")
return TemplateResponse(request, "taskito/admin/dashboard.html", context)


Expand Down Expand Up @@ -57,13 +79,15 @@ def _jobs_view(request: HttpRequest, site: Any) -> HttpResponse:

logging.getLogger(__name__).exception("Failed to list jobs")
jobs = []
context = {
**site.each_context(request),
"jobs": [j.to_dict() for j in jobs],
"filters": {"status": status, "queue": queue_name, "task_name": task_name},
"page": page,
"title": "Taskito Jobs",
}
context = _base_context(
request,
site,
jobs=[j.to_dict() for j in jobs],
filters={"status": status, "queue": queue_name, "task_name": task_name},
page=page,
has_next=len(jobs) == per_page,
title="Taskito Jobs",
)
return TemplateResponse(request, "taskito/admin/jobs.html", context)


Expand All @@ -73,12 +97,13 @@ def _job_detail_view(request: HttpRequest, site: Any, job_id: str) -> HttpRespon
queue = get_queue()
job = queue.get_job(job_id)
errors = queue.job_errors(job_id) if job else []
context = {
**site.each_context(request),
"job": job.to_dict() if job else None,
"errors": errors,
"title": f"Job {job_id}",
}
context = _base_context(
request,
site,
job=job.to_dict() if job else None,
errors=errors,
title=f"Job {job_id}",
)
return TemplateResponse(request, "taskito/admin/job_detail.html", context)


Expand All @@ -102,12 +127,14 @@ def _dead_letters_view(request: HttpRequest, site: Any) -> HttpResponse:

per_page = getattr(django_settings, "TASKITO_ADMIN_PER_PAGE", 50)
dead = queue.dead_letters(limit=per_page, offset=(page - 1) * per_page)
context = {
**site.each_context(request),
"dead_letters": dead,
"page": page,
"title": "Taskito Dead Letters",
}
context = _base_context(
request,
site,
dead_letters=dead,
page=page,
has_next=len(dead) == per_page,
title="Taskito Dead Letters",
)
return TemplateResponse(request, "taskito/admin/dead_letters.html", context)


Expand Down Expand Up @@ -179,13 +206,29 @@ def dashboard_view(request: HttpRequest) -> HttpResponse:
def jobs_view(request: HttpRequest) -> HttpResponse:
return _jobs_view(request, target)

def job_detail_view(request: HttpRequest, job_id: str) -> HttpResponse:
return _job_detail_view(request, target, job_id)

def dead_letters_view(request: HttpRequest) -> HttpResponse:
return _dead_letters_view(request, target)

original_get_urls = target.get_urls

def patched_get_urls() -> list:
urls = original_get_urls()
custom = [
path("taskito/", target.admin_view(dashboard_view), name="taskito_dashboard"),
path("taskito/jobs/", target.admin_view(jobs_view), name="taskito_jobs"),
path(
"taskito/jobs/<str:job_id>/",
target.admin_view(job_detail_view),
name="taskito_job_detail",
),
path(
"taskito/dead-letters/",
target.admin_view(dead_letters_view),
name="taskito_dead_letters",
),
]
return custom + urls # type: ignore[no-any-return]

Expand Down
10 changes: 10 additions & 0 deletions py_src/taskito/contrib/django/templates/taskito/admin/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "admin/base_site.html" %}

{% block content %}
<ul class="object-tools">
<li><a href="{{ taskito_urls.dashboard }}">Dashboard</a></li>
<li><a href="{{ taskito_urls.jobs }}">Jobs</a></li>
<li><a href="{{ taskito_urls.dead_letters }}">Dead Letters</a></li>
</ul>
{% block taskito_content %}{% endblock %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% extends "taskito/admin/base.html" %}

{% block taskito_content %}
<h1>Taskito Dashboard</h1>

{% if stats %}
<div class="module">
<table>
<thead>
<tr><th>Status</th><th>Count</th></tr>
</thead>
<tbody>
{% for name, count in stats.items %}
<tr>
<td>{{ name }}</td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No queue statistics available.</p>
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends "taskito/admin/base.html" %}
{% load taskito_admin %}

{% block taskito_content %}
<h1>Dead Letters</h1>

{% if dead_letters %}
<div class="module">
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Queue</th>
<th>Retries</th>
<th>Failed</th>
<th>Error</th>
<th></th>
</tr>
</thead>
<tbody>
{% for dead in dead_letters %}
<tr>
<td>{{ dead.id }}</td>
<td>{{ dead.task_name }}</td>
<td>{{ dead.queue }}</td>
<td>{{ dead.retry_count }}</td>
<td>{{ dead.failed_at|ms_datetime }}</td>
<td><pre>{{ dead.error }}</pre></td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="retry">
<input type="hidden" name="dead_id" value="{{ dead.id }}">
<input type="submit" value="Retry">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

<p class="paginator">
{% if page > 1 %}<a href="?page={{ page|add:'-1' }}">Previous</a>{% endif %}
Page {{ page }}
{% if has_next %}<a href="?page={{ page|add:'1' }}">Next</a>{% endif %}
</p>
{% else %}
<p>No dead letters.</p>
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{% extends "taskito/admin/base.html" %}
{% load taskito_admin %}

{% block taskito_content %}
<p><a href="{{ taskito_urls.jobs }}">&larr; Back to jobs</a></p>

{% if job %}
<h1>Job {{ job.id }}</h1>

<div class="module">
<table>
<tbody>
<tr><th>Status</th><td>{{ job.status }}</td></tr>
<tr><th>Task</th><td>{{ job.task_name }}</td></tr>
<tr><th>Queue</th><td>{{ job.queue }}</td></tr>
<tr><th>Priority</th><td>{{ job.priority }}</td></tr>
<tr><th>Progress</th><td>{{ job.progress }}</td></tr>
<tr><th>Retries</th><td>{{ job.retry_count }}/{{ job.max_retries }}</td></tr>
<tr><th>Created</th><td>{{ job.created_at|ms_datetime }}</td></tr>
<tr><th>Scheduled</th><td>{{ job.scheduled_at|ms_datetime }}</td></tr>
<tr><th>Started</th><td>{{ job.started_at|ms_datetime }}</td></tr>
<tr><th>Completed</th><td>{{ job.completed_at|ms_datetime }}</td></tr>
{% if job.error %}<tr><th>Error</th><td><pre>{{ job.error }}</pre></td></tr>{% endif %}
</tbody>
</table>
</div>

{% if errors %}
<h2>Error history</h2>
<div class="module">
<table>
<thead>
<tr><th>Attempt</th><th>When</th><th>Error</th></tr>
</thead>
<tbody>
{% for err in errors %}
<tr>
<td>{{ err.attempt }}</td>
<td>{{ err.failed_at|ms_datetime }}</td>
<td><pre>{{ err.error }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% else %}
<p>Job not found.</p>
{% endif %}
{% endblock %}
54 changes: 54 additions & 0 deletions py_src/taskito/contrib/django/templates/taskito/admin/jobs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{% extends "taskito/admin/base.html" %}
{% load taskito_admin %}

{% block taskito_content %}
<h1>Jobs</h1>

<form method="get" id="changelist-search">
<input type="text" name="status" value="{{ filters.status|default:'' }}" placeholder="status">
<input type="text" name="queue" value="{{ filters.queue|default:'' }}" placeholder="queue">
<input type="text" name="task_name" value="{{ filters.task_name|default:'' }}" placeholder="task name">
<input type="submit" value="Filter">
</form>

{% if jobs %}
<div class="module">
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Queue</th>
<th>Status</th>
<th>Retries</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td><a href="{% url taskito_urls.job_detail_name job.id %}">{{ job.id }}</a></td>
<td>{{ job.task_name }}</td>
<td>{{ job.queue }}</td>
<td>{{ job.status }}</td>
<td>{{ job.retry_count }}/{{ job.max_retries }}</td>
<td>{{ job.created_at|ms_datetime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

<p class="paginator">
{% if page > 1 %}
<a href="?{% if filters.status %}status={{ filters.status }}&amp;{% endif %}{% if filters.queue %}queue={{ filters.queue }}&amp;{% endif %}{% if filters.task_name %}task_name={{ filters.task_name }}&amp;{% endif %}page={{ page|add:'-1' }}">Previous</a>
{% endif %}
Page {{ page }}
{% if has_next %}
<a href="?{% if filters.status %}status={{ filters.status }}&amp;{% endif %}{% if filters.queue %}queue={{ filters.queue }}&amp;{% endif %}{% if filters.task_name %}task_name={{ filters.task_name }}&amp;{% endif %}page={{ page|add:'1' }}">Next</a>
{% endif %}
</p>
{% else %}
<p>No jobs found.</p>
{% endif %}
{% endblock %}
Empty file.
36 changes: 36 additions & 0 deletions py_src/taskito/contrib/django/templatetags/taskito_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Template filters for the taskito Django admin views.

Taskito stores every timestamp as Unix **milliseconds** (the dashboard API
contract), so the stock Django ``|date`` filter — which expects seconds or a
``datetime`` — cannot format them directly. ``ms_datetime`` bridges that gap.
"""

from __future__ import annotations

from datetime import datetime, timezone

try:
from django import template
except ImportError as e: # pragma: no cover - import guard mirrors sibling modules
raise ImportError(
"Django integration requires 'django'. Install with: pip install taskito[django]"
) from e

register = template.Library()


@register.filter
def ms_datetime(value: object) -> str:
"""Render a Unix-millisecond timestamp as a human-readable UTC string.

Returns an em dash for ``None`` or values that aren't a finite number, so
templates never show ``None`` or raise on malformed data.
"""
if value is None:
return "—"
try:
seconds = float(value) / 1000.0 # type: ignore[arg-type]
moment = datetime.fromtimestamp(seconds, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError):
return "—"
return moment.strftime("%Y-%m-%d %H:%M:%S UTC")
Loading