From ba1060d7160f148724cd715e80c5bbf21cc5809e Mon Sep 17 00:00:00 2001 From: Nicolas Delaby Date: Thu, 1 Aug 2024 17:37:48 +0200 Subject: [PATCH 1/7] Make django Admin interface support slightly better UX wise - Display some fields on table view ["task_name", "queue_name", "attempts", "status"] - Allow to filter on status - Display Events inline --- procrastinate/contrib/django/admin.py | 74 +++++++++++++++++-- .../0030_alter_procrastinateevent_options.py | 17 +++++ procrastinate/contrib/django/models.py | 1 + .../contrib/django/templates/summary.html | 1 + 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py create mode 100644 procrastinate/contrib/django/templates/summary.html diff --git a/procrastinate/contrib/django/admin.py b/procrastinate/contrib/django/admin.py index 6d81bddc6..f067c0221 100644 --- a/procrastinate/contrib/django/admin.py +++ b/procrastinate/contrib/django/admin.py @@ -1,11 +1,56 @@ from __future__ import annotations from django.contrib import admin +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe from . import models +JOB_STATUS_EMOJI_MAPPING = { + "todo": "🗓️", + "doing": "🚂", + "failed": "❌", + "succeeded": "✅", + "cancelled": "🤚", + "aborting": "🔌🕑️", + "aborted": "🔌", +} + + +class ProcrastinateEventInline(admin.StackedInline): + model = models.ProcrastinateEvent + + +@admin.register(models.ProcrastinateJob) +class ProcrastinateJobAdmin(admin.ModelAdmin): + fields = [ + "pk", + "short_task_name", + "pretty_args", + "pretty_status", + "queue_name", + "lock", + "queueing_lock", + "priority", + "scheduled_at", + "attempts", + "summary", + ] + list_display = [ + "pk", + "short_task_name", + "pretty_args", + "pretty_status", + "queue_name", + "lock", + "queueing_lock", + "priority", + "scheduled_at", + "attempts", + ] + list_filter = ["status"] + inlines = [ProcrastinateEventInline] -class ProcrastinateAdmin(admin.ModelAdmin): def get_readonly_fields( self, request, @@ -26,11 +71,24 @@ def has_add_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None): return False + @admin.display(description="Status") + def pretty_status(self, instance: models.ProcrastinateJob) -> str: + emoji = JOB_STATUS_EMOJI_MAPPING.get(instance.status, "") + return f"{emoji} ({instance.status})" + + @admin.display(description="Task Name") + def short_task_name(self, instance: models.ProcrastinateJob) -> str: + *modules, name = instance.task_name.split(".") + return ".".join(m[0] for m in modules) + f".{name}" + + @admin.display(description="Args") + def pretty_args(self, instance: models.ProcrastinateJob) -> str: + return mark_safe(f"
{instance.args}
") -admin.site.register( - [ - models.ProcrastinateJob, - models.ProcrastinateEvent, - ], - ProcrastinateAdmin, -) + @admin.display(description="Summary") + def summary(self, instance: models.ProcrastinateJob) -> str: + if last_event := instance.procrastinateevent_set.latest(): # type: ignore[attr-defined] + return mark_safe( + render_to_string("summary.html", {"last_event": last_event}).strip() + ) + return "" diff --git a/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py b/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py new file mode 100644 index 000000000..7b5f72594 --- /dev/null +++ b/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.8 on 2024-08-08 14:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('procrastinate', '0029_add_additional_params_to_retry_job'), + ] + + operations = [ + migrations.AlterModelOptions( + name='procrastinateevent', + options={'get_latest_by': 'at', 'managed': False}, + ), + ] diff --git a/procrastinate/contrib/django/models.py b/procrastinate/contrib/django/models.py index 3dfeb1494..6c00103fb 100644 --- a/procrastinate/contrib/django/models.py +++ b/procrastinate/contrib/django/models.py @@ -108,6 +108,7 @@ class ProcrastinateEvent(ProcrastinateReadOnlyModelMixin, models.Model): class Meta: # type: ignore managed = False db_table = "procrastinate_events" + get_latest_by = "at" class ProcrastinatePeriodicDefer(ProcrastinateReadOnlyModelMixin, models.Model): diff --git a/procrastinate/contrib/django/templates/summary.html b/procrastinate/contrib/django/templates/summary.html new file mode 100644 index 000000000..f0ec3b9df --- /dev/null +++ b/procrastinate/contrib/django/templates/summary.html @@ -0,0 +1 @@ +{{ last_event.type | title }} {{ last_event.at|timesince }} ago From e26003746a350ff689a412ed70b7a1c7cbaeedee Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 8 Aug 2024 14:28:28 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../migrations/0030_alter_procrastinateevent_options.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py b/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py index 7b5f72594..758c5a43c 100644 --- a/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py +++ b/procrastinate/contrib/django/migrations/0030_alter_procrastinateevent_options.py @@ -1,17 +1,17 @@ # Generated by Django 5.0.8 on 2024-08-08 14:27 +from __future__ import annotations from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('procrastinate', '0029_add_additional_params_to_retry_job'), + ("procrastinate", "0029_add_additional_params_to_retry_job"), ] operations = [ migrations.AlterModelOptions( - name='procrastinateevent', - options={'get_latest_by': 'at', 'managed': False}, + name="procrastinateevent", + options={"get_latest_by": "at", "managed": False}, ), ] From 6086a9602ada49a21cf38e6ca3c61600648224f1 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Thu, 8 Aug 2024 23:35:03 +0200 Subject: [PATCH 3/7] Move summary.html to procrastinate/admin/summary.html to avoid shadowing Also, add many other parts to the summary --- .../procrastinate/admin/summary.html | 48 +++++++++++++++++++ .../contrib/django/templates/summary.html | 1 - 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 procrastinate/contrib/django/templates/procrastinate/admin/summary.html delete mode 100644 procrastinate/contrib/django/templates/summary.html diff --git a/procrastinate/contrib/django/templates/procrastinate/admin/summary.html b/procrastinate/contrib/django/templates/procrastinate/admin/summary.html new file mode 100644 index 000000000..733c66b97 --- /dev/null +++ b/procrastinate/contrib/django/templates/procrastinate/admin/summary.html @@ -0,0 +1,48 @@ + diff --git a/procrastinate/contrib/django/templates/summary.html b/procrastinate/contrib/django/templates/summary.html deleted file mode 100644 index f0ec3b9df..000000000 --- a/procrastinate/contrib/django/templates/summary.html +++ /dev/null @@ -1 +0,0 @@ -{{ last_event.type | title }} {{ last_event.at|timesince }} ago From de8593c5500f205caa2ab9d2decaa178ecb846bb Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Thu, 8 Aug 2024 23:36:18 +0200 Subject: [PATCH 4/7] Continue improving the admin with small details --- procrastinate/contrib/django/admin.py | 39 +++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/procrastinate/contrib/django/admin.py b/procrastinate/contrib/django/admin.py index f067c0221..80576953f 100644 --- a/procrastinate/contrib/django/admin.py +++ b/procrastinate/contrib/django/admin.py @@ -1,7 +1,11 @@ from __future__ import annotations +import json + from django.contrib import admin from django.template.loader import render_to_string +from django.utils import timezone +from django.utils.html import format_html from django.utils.safestring import mark_safe from . import models @@ -34,21 +38,23 @@ class ProcrastinateJobAdmin(admin.ModelAdmin): "priority", "scheduled_at", "attempts", - "summary", ] list_display = [ "pk", "short_task_name", "pretty_args", "pretty_status", + "summary", + ] + list_filter = [ + "status", "queue_name", + "task_name", "lock", "queueing_lock", - "priority", "scheduled_at", - "attempts", + "priority", ] - list_filter = ["status"] inlines = [ProcrastinateEventInline] def get_readonly_fields( @@ -74,21 +80,38 @@ def has_delete_permission(self, request, obj=None): @admin.display(description="Status") def pretty_status(self, instance: models.ProcrastinateJob) -> str: emoji = JOB_STATUS_EMOJI_MAPPING.get(instance.status, "") - return f"{emoji} ({instance.status})" + return f"{emoji} {instance.status.title()}" @admin.display(description="Task Name") def short_task_name(self, instance: models.ProcrastinateJob) -> str: *modules, name = instance.task_name.split(".") - return ".".join(m[0] for m in modules) + f".{name}" + return format_html( + "{name}", + task_name=instance.task_name, + name=".".join(m[0] for m in modules) + f".{name}", + ) @admin.display(description="Args") def pretty_args(self, instance: models.ProcrastinateJob) -> str: - return mark_safe(f"
{instance.args}
") + indent = 2 if len(instance.args) > 1 or len(str(instance.args)) > 30 else None + pretty_json = json.dumps(instance.args, indent=indent) + if len(pretty_json) > 2000: + pretty_json = pretty_json[:2000] + "..." + return format_html( + '
{pretty_json}
', pretty_json=pretty_json + ) @admin.display(description="Summary") def summary(self, instance: models.ProcrastinateJob) -> str: if last_event := instance.procrastinateevent_set.latest(): # type: ignore[attr-defined] return mark_safe( - render_to_string("summary.html", {"last_event": last_event}).strip() + render_to_string( + "procrastinate/admin/summary.html", + { + "last_event": last_event, + "job": instance, + "now": timezone.now(), + }, + ).strip() ) return "" From 97f62488e9d44d9e545dca62b7c10c398307095d Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Thu, 8 Aug 2024 23:39:12 +0200 Subject: [PATCH 5/7] Add __str__ to procrastinate models to improve appearance in the admin too --- procrastinate/contrib/django/models.py | 20 +++++++++++++ tests/acceptance/django_settings.py | 6 ++++ .../integration/contrib/django/test_models.py | 28 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/procrastinate/contrib/django/models.py b/procrastinate/contrib/django/models.py index 6c00103fb..97194b709 100644 --- a/procrastinate/contrib/django/models.py +++ b/procrastinate/contrib/django/models.py @@ -4,6 +4,8 @@ from django.db import models +from procrastinate import jobs + from . import exceptions, settings @@ -85,6 +87,24 @@ class Meta: # type: ignore managed = False db_table = "procrastinate_jobs" + @property + def procrastinate_job(self) -> jobs.Job: + return jobs.Job( + id=self.id, + queue=self.queue_name, + task_name=self.task_name, + task_kwargs=self.args, + priority=self.priority, + lock=self.lock, + status=self.status, + scheduled_at=self.scheduled_at, + attempts=self.attempts, + queueing_lock=self.queueing_lock, + ) + + def __str__(self) -> str: + return self.procrastinate_job.call_string + class ProcrastinateEvent(ProcrastinateReadOnlyModelMixin, models.Model): TYPES = ( diff --git a/tests/acceptance/django_settings.py b/tests/acceptance/django_settings.py index c2b87cb5a..97a4d761c 100644 --- a/tests/acceptance/django_settings.py +++ b/tests/acceptance/django_settings.py @@ -11,6 +11,12 @@ }, } INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", "procrastinate.contrib.django", ] USE_TZ = True # To avoid RemovedInDjango50Warning diff --git a/tests/integration/contrib/django/test_models.py b/tests/integration/contrib/django/test_models.py index f3fe8ba4a..022c81017 100644 --- a/tests/integration/contrib/django/test_models.py +++ b/tests/integration/contrib/django/test_models.py @@ -8,6 +8,7 @@ import procrastinate import procrastinate.contrib.django import procrastinate.contrib.django.exceptions +from procrastinate import jobs as jobs_module from procrastinate.contrib.django import models @@ -30,6 +31,33 @@ def test_procrastinate_job(db): } +def test_procrastinate_job__property(db): + job = models.ProcrastinateJob( + id=1, + queue_name="foo", + task_name="test_task", + priority=0, + lock="bar", + args={"a": 1, "b": 2}, + status="todo", + scheduled_at=datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc), + attempts=0, + queueing_lock="baz", + ) + assert job.procrastinate_job == jobs_module.Job( + id=1, + queue="foo", + task_name="test_task", + task_kwargs={"a": 1, "b": 2}, + priority=0, + lock="bar", + status="todo", + scheduled_at=datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc), + attempts=0, + queueing_lock="baz", + ) + + def test_procrastinate_job__no_create(db): with pytest.raises(procrastinate.contrib.django.exceptions.ReadOnlyModel): models.ProcrastinateJob.objects.create(task_name="test_task") From 7a6c2a48677aec10bb77153f775f4edd871e7def Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Thu, 8 Aug 2024 23:39:40 +0200 Subject: [PATCH 6/7] Ensure the admin status emojis won't get out of sync with real statuses --- tests/unit/contrib/django/test_admin.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/unit/contrib/django/test_admin.py diff --git a/tests/unit/contrib/django/test_admin.py b/tests/unit/contrib/django/test_admin.py new file mode 100644 index 000000000..53b25056c --- /dev/null +++ b/tests/unit/contrib/django/test_admin.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from procrastinate import jobs +from procrastinate.contrib.django import admin + + +def test_emoji_mapping(): + assert set(admin.JOB_STATUS_EMOJI_MAPPING) == {e.value for e in jobs.Status} From ed99be3afba8518987caf51223570d2e250a9627 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Thu, 8 Aug 2024 23:48:56 +0200 Subject: [PATCH 7/7] Add __str__ for events too --- procrastinate/contrib/django/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/procrastinate/contrib/django/models.py b/procrastinate/contrib/django/models.py index 97194b709..23206d710 100644 --- a/procrastinate/contrib/django/models.py +++ b/procrastinate/contrib/django/models.py @@ -130,6 +130,9 @@ class Meta: # type: ignore db_table = "procrastinate_events" get_latest_by = "at" + def __str__(self) -> str: + return f"Event {self.id} - Job {self.job_id}: {self.type} at {self.at}" # type: ignore + class ProcrastinatePeriodicDefer(ProcrastinateReadOnlyModelMixin, models.Model): id = models.BigAutoField(primary_key=True)