| Status | Count |
|---|---|
| {{ name }} | +{{ count }} | +
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 %} +| ID | +Task | +Queue | +Retries | +Failed | +Error | ++ |
|---|---|---|---|---|---|---|
| {{ dead.id }} | +{{ dead.task_name }} | +{{ dead.queue }} | +{{ dead.retry_count }} | +{{ dead.failed_at|ms_datetime }} | +{{ dead.error }} |
+ + + | +
+ {% 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 %} + + +{% if job %} +| 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 }} |
| Attempt | When | Error |
|---|---|---|
| {{ err.attempt }} | +{{ err.failed_at|ms_datetime }} | +{{ err.error }} |
+
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 %} +| ID | +Task | +Queue | +Status | +Retries | +Created | +
|---|---|---|---|---|---|
| {{ 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"])