diff --git a/docs/content/docs/guides/integrations/django.mdx b/docs/content/docs/guides/integrations/django.mdx index cdb1a340..1b31753b 100644 --- a/docs/content/docs/guides/integrations/django.mdx +++ b/docs/content/docs/guides/integrations/django.mdx @@ -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", +] +``` + + + Keep the default `APP_DIRS = True` in your `TEMPLATES` setting (Django's + default) so the bundled admin templates are discovered. + + ### Option 1: register on the default admin site In your project's `admin.py` or `urls.py`: diff --git a/py_src/taskito/contrib/django/admin.py b/py_src/taskito/contrib/django/admin.py index 6d0bc0b6..66fcb80b 100644 --- a/py_src/taskito/contrib/django/admin.py +++ b/py_src/taskito/contrib/django/admin.py @@ -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) @@ -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) @@ -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) @@ -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) @@ -179,6 +206,12 @@ 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: @@ -186,6 +219,16 @@ def patched_get_urls() -> list: 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//", + 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] diff --git a/py_src/taskito/contrib/django/templates/taskito/admin/base.html b/py_src/taskito/contrib/django/templates/taskito/admin/base.html new file mode 100644 index 00000000..d1a6ef5f --- /dev/null +++ b/py_src/taskito/contrib/django/templates/taskito/admin/base.html @@ -0,0 +1,10 @@ +{% extends "admin/base_site.html" %} + +{% block content %} + +{% block taskito_content %}{% endblock %} +{% endblock %} diff --git a/py_src/taskito/contrib/django/templates/taskito/admin/dashboard.html b/py_src/taskito/contrib/django/templates/taskito/admin/dashboard.html new file mode 100644 index 00000000..f97b8b61 --- /dev/null +++ b/py_src/taskito/contrib/django/templates/taskito/admin/dashboard.html @@ -0,0 +1,25 @@ +{% extends "taskito/admin/base.html" %} + +{% block taskito_content %} +

Taskito Dashboard

+ +{% if stats %} +
+ + + + + + {% for name, count in stats.items %} + + + + + {% endfor %} + +
StatusCount
{{ name }}{{ count }}
+
+{% else %} +

No queue statistics available.

+{% endif %} +{% endblock %} diff --git a/py_src/taskito/contrib/django/templates/taskito/admin/dead_letters.html b/py_src/taskito/contrib/django/templates/taskito/admin/dead_letters.html new file mode 100644 index 00000000..89bc492c --- /dev/null +++ b/py_src/taskito/contrib/django/templates/taskito/admin/dead_letters.html @@ -0,0 +1,52 @@ +{% extends "taskito/admin/base.html" %} +{% load taskito_admin %} + +{% block taskito_content %} +

Dead Letters

+ +{% if dead_letters %} +
+ + + + + + + + + + + + + + {% for dead in dead_letters %} + + + + + + + + + + {% endfor %} + +
IDTaskQueueRetriesFailedError
{{ dead.id }}{{ dead.task_name }}{{ dead.queue }}{{ dead.retry_count }}{{ dead.failed_at|ms_datetime }}
{{ dead.error }}
+
+ {% csrf_token %} + + + +
+
+
+ +

+ {% if page > 1 %}Previous{% endif %} + Page {{ page }} + {% if has_next %}Next{% endif %} +

+{% else %} +

No dead letters.

+{% endif %} +{% endblock %} diff --git a/py_src/taskito/contrib/django/templates/taskito/admin/job_detail.html b/py_src/taskito/contrib/django/templates/taskito/admin/job_detail.html new file mode 100644 index 00000000..75bf1359 --- /dev/null +++ b/py_src/taskito/contrib/django/templates/taskito/admin/job_detail.html @@ -0,0 +1,50 @@ +{% extends "taskito/admin/base.html" %} +{% load taskito_admin %} + +{% block taskito_content %} +

← Back to jobs

+ +{% if job %} +

Job {{ job.id }}

+ +
+ + + + + + + + + + + + + {% if job.error %}{% endif %} + +
Status{{ job.status }}
Task{{ job.task_name }}
Queue{{ job.queue }}
Priority{{ job.priority }}
Progress{{ job.progress }}
Retries{{ job.retry_count }}/{{ job.max_retries }}
Created{{ job.created_at|ms_datetime }}
Scheduled{{ job.scheduled_at|ms_datetime }}
Started{{ job.started_at|ms_datetime }}
Completed{{ job.completed_at|ms_datetime }}
Error
{{ job.error }}
+
+ +{% if errors %} +

Error history

+
+ + + + + + {% for err in errors %} + + + + + + {% endfor %} + +
AttemptWhenError
{{ err.attempt }}{{ err.failed_at|ms_datetime }}
{{ err.error }}
+
+{% endif %} +{% else %} +

Job not found.

+{% endif %} +{% endblock %} diff --git a/py_src/taskito/contrib/django/templates/taskito/admin/jobs.html b/py_src/taskito/contrib/django/templates/taskito/admin/jobs.html new file mode 100644 index 00000000..9342f9e8 --- /dev/null +++ b/py_src/taskito/contrib/django/templates/taskito/admin/jobs.html @@ -0,0 +1,54 @@ +{% extends "taskito/admin/base.html" %} +{% load taskito_admin %} + +{% block taskito_content %} +

Jobs

+ + + +{% if jobs %} +
+ + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
IDTaskQueueStatusRetriesCreated
{{ job.id }}{{ job.task_name }}{{ job.queue }}{{ job.status }}{{ job.retry_count }}/{{ job.max_retries }}{{ job.created_at|ms_datetime }}
+
+ +

+ {% if page > 1 %} + Previous + {% endif %} + Page {{ page }} + {% if has_next %} + Next + {% endif %} +

+{% else %} +

No jobs found.

+{% endif %} +{% endblock %} diff --git a/py_src/taskito/contrib/django/templatetags/__init__.py b/py_src/taskito/contrib/django/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/py_src/taskito/contrib/django/templatetags/taskito_admin.py b/py_src/taskito/contrib/django/templatetags/taskito_admin.py new file mode 100644 index 00000000..91b96a48 --- /dev/null +++ b/py_src/taskito/contrib/django/templatetags/taskito_admin.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index d715365d..1ae7a38f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,11 +56,9 @@ manifest-path = "crates/taskito-python/Cargo.toml" python-source = "py_src" module-name = "taskito._taskito" features = ["extension-module", "postgres", "redis", "workflows"] -# Maturin's `python-source` only auto-packages Python sources (`.py`, `.pyi`, -# `py.typed`). The compiled dashboard SPA must be opted in explicitly so it -# ships in both the wheel and the sdist. include = [ { path = "py_src/taskito/static/dashboard/**/*", format = ["wheel", "sdist"] }, + { path = "py_src/taskito/contrib/django/templates/**/*", format = ["wheel", "sdist"] }, ] [project.scripts] diff --git a/tests/integrations/test_django_admin.py b/tests/integrations/test_django_admin.py new file mode 100644 index 00000000..f24708b0 --- /dev/null +++ b/tests/integrations/test_django_admin.py @@ -0,0 +1,149 @@ +"""Tests for the Django admin integration (issue #261). + +Regression coverage: the admin views render four templates via +``TemplateResponse``; before this fix those templates were never shipped, so +every admin page raised ``TemplateDoesNotExist``. These tests render each view +end-to-end and assert a 200 with real content, which fails loudly if a template +(or its packaging) goes missing again. + +Views are exercised directly (not through ``admin_view``) so no auth database or +migrations are needed — ``each_context`` short-circuits for an anonymous user. +""" + +from __future__ import annotations + +import pytest + +# Skip the whole module when Django isn't installed (it's an optional extra). +pytest.importorskip("django") + +from django.conf import settings + +if not settings.configured: + settings.configure( + DEBUG=True, + DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.auth", + "django.contrib.messages", + "django.contrib.sessions", + "django.contrib.admin", + "taskito.contrib.django", + ], + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + } + ], + ROOT_URLCONF=__name__, + MIDDLEWARE=[], + SECRET_KEY="test-only", + USE_TZ=True, + ) + import django + + django.setup() + +from collections.abc import Callable + +from django.contrib import admin +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory +from django.urls import path, reverse + +import taskito.contrib.django.settings as tk_settings +from taskito import Queue +from taskito.contrib.django import admin as tk_admin + +# Wire the four taskito routes onto the default admin site so reverse() resolves +# them, then expose that site as this module's URLconf (ROOT_URLCONF above). +tk_admin.register_taskito_admin() +urlpatterns = [path("admin/", admin.site.urls)] + + +@pytest.fixture +def rf() -> RequestFactory: + return RequestFactory() + + +@pytest.fixture +def patched_queue(queue: Queue, monkeypatch: pytest.MonkeyPatch) -> Queue: + """Point the Django integration's singleton accessor at the temp queue.""" + monkeypatch.setattr(tk_settings, "get_queue", lambda: queue) + return queue + + +def _render(view: Callable[..., object], request: object, *args: object) -> object: + request.user = AnonymousUser() # type: ignore[attr-defined] + response = view(request, admin.site, *args) + response.render() # type: ignore[attr-defined] + return response + + +def test_dashboard_renders(rf: RequestFactory, patched_queue: Queue) -> None: + response = _render(tk_admin._dashboard_view, rf.get("/admin/taskito/")) + assert response.status_code == 200 # type: ignore[attr-defined] + assert b"Taskito Dashboard" in response.content # type: ignore[attr-defined] + + +def test_jobs_view_lists_enqueued_job(rf: RequestFactory, patched_queue: Queue) -> None: + @patched_queue.task() + def add(x: int, y: int) -> int: + return x + y + + job_id = add.delay(1, 2).id + + response = _render(tk_admin._jobs_view, rf.get("/admin/taskito/jobs/")) + assert response.status_code == 200 # type: ignore[attr-defined] + assert job_id.encode() in response.content # type: ignore[attr-defined] + + +def test_job_detail_renders(rf: RequestFactory, patched_queue: Queue) -> None: + @patched_queue.task() + def noop() -> None: + return None + + job_id = noop.delay().id + + response = _render(tk_admin._job_detail_view, rf.get(f"/admin/taskito/jobs/{job_id}/"), job_id) + assert response.status_code == 200 # type: ignore[attr-defined] + assert job_id.encode() in response.content # type: ignore[attr-defined] + + +def test_dead_letters_empty_renders(rf: RequestFactory, patched_queue: Queue) -> None: + response = _render(tk_admin._dead_letters_view, rf.get("/admin/taskito/dead-letters/")) + assert response.status_code == 200 # type: ignore[attr-defined] + assert b"No dead letters" in response.content # type: ignore[attr-defined] + + +def test_dead_letters_retry_posts_to_queue( + rf: RequestFactory, patched_queue: Queue, monkeypatch: pytest.MonkeyPatch +) -> None: + retried: list[str] = [] + + def record_retry(dead_id: str) -> str: + retried.append(dead_id) + return "new" + + monkeypatch.setattr(patched_queue, "retry_dead", record_retry) + + request = rf.post("/admin/taskito/dead-letters/", {"action": "retry", "dead_id": "abc123"}) + _render(tk_admin._dead_letters_view, request) + assert retried == ["abc123"] + + +def test_register_taskito_admin_wires_all_routes() -> None: + # Parity with TaskitoAdminSite — all four named routes must resolve. + assert reverse("admin:taskito_dashboard") + assert reverse("admin:taskito_jobs") + assert reverse("admin:taskito_dead_letters") + assert reverse("admin:taskito_job_detail", args=["xyz"])