django-celery-logs 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ default_app_config = "django_celery_logs.apps.CeleryLogsConfig"
@@ -0,0 +1,358 @@
1
+ import json
2
+ from datetime import timedelta
3
+
4
+ from celery import current_app
5
+ from django.conf import settings
6
+ from django.contrib import admin, messages
7
+ from django.db.models import Avg, Count, JSONField, Max, Min, Q
8
+ from django.http import HttpResponseRedirect
9
+ from django.urls import path, reverse
10
+ from django.utils import timezone
11
+ from django.utils.html import escape
12
+ from django.utils.safestring import mark_safe
13
+ from django.utils.translation import gettext_lazy as _
14
+
15
+ from .models import TaskConfig, TaskLog, TaskLogStatistics
16
+ from .utils import sort_dict_recursively
17
+ from .widgets import PrettyJSONWidget
18
+
19
+
20
+ @admin.register(TaskConfig)
21
+ class TaskConfigAdmin(admin.ModelAdmin):
22
+ list_display = ("logs_expiration_days",)
23
+
24
+ def has_add_permission(self, request):
25
+ return not TaskConfig.objects.exists()
26
+
27
+ def has_delete_permission(self, request, obj=None):
28
+ return False
29
+
30
+
31
+ @admin.register(TaskLog)
32
+ class TaskLogAdmin(admin.ModelAdmin):
33
+ model = TaskLog
34
+ date_hierarchy = "timestamp"
35
+ list_display = ("id", "task_name", "_timestamp", "status", "result_snippet")
36
+ list_filter = ("status", "timestamp", "task_name", "queue_name")
37
+ readonly_fields = (
38
+ "timestamp",
39
+ "worker",
40
+ "status",
41
+ "task_id",
42
+ "task_name",
43
+ "periodic_task_name",
44
+ "queue_name",
45
+ "exc_type",
46
+ "exc_msg",
47
+ "result_pretty",
48
+ "traceback_pretty",
49
+ )
50
+ search_fields = ("task_name", "task_id", "worker")
51
+ fieldsets = (
52
+ (
53
+ None,
54
+ {
55
+ "fields": ("task_id", "task_name", "periodic_task_name", "queue_name", "status", "worker"),
56
+ "classes": ("extrapretty", "wide"),
57
+ },
58
+ ),
59
+ (_("Parameters"), {"fields": ("task_args", "task_kwargs"), "classes": ("extrapretty", "wide")}),
60
+ (
61
+ _("Result"),
62
+ {
63
+ "fields": ("result_pretty", "timestamp", "exc_type", "exc_msg", "traceback_pretty"),
64
+ "classes": ("extrapretty", "wide"),
65
+ },
66
+ ),
67
+ )
68
+ formfield_overrides = {JSONField: {"widget": PrettyJSONWidget}}
69
+ list_per_page = 25
70
+
71
+ def get_changelist_instance(self, request):
72
+ list_per_page_options = [25, 50, 75, 100]
73
+ selected_per_page = request.GET.get("list_per_page", "25")
74
+
75
+ try:
76
+ selected_per_page = int(selected_per_page)
77
+ self.list_per_page = selected_per_page if selected_per_page in list_per_page_options else 25
78
+ except (ValueError, TypeError):
79
+ self.list_per_page = 25
80
+
81
+ if "list_per_page" in request.GET:
82
+ get_params = request.GET.copy()
83
+ del get_params["list_per_page"]
84
+ request.GET = get_params
85
+
86
+ return super().get_changelist_instance(request)
87
+
88
+ def changelist_view(self, request, extra_context=None):
89
+ list_per_page_options = [25, 50, 75, 100]
90
+ selected_per_page = request.GET.get("list_per_page", "25")
91
+
92
+ try:
93
+ selected_per_page = int(selected_per_page)
94
+ if selected_per_page not in list_per_page_options:
95
+ selected_per_page = 25
96
+ except (ValueError, TypeError):
97
+ selected_per_page = 25
98
+
99
+ extra_context = extra_context or {}
100
+ extra_context.update(
101
+ {
102
+ "list_per_page_options": list_per_page_options,
103
+ "selected_per_page": selected_per_page,
104
+ }
105
+ )
106
+ return super().changelist_view(request, extra_context=extra_context)
107
+
108
+ def has_add_permission(self, request):
109
+ return False
110
+
111
+ def has_change_permission(self, request, obj=None):
112
+ return False
113
+
114
+ def get_urls(self):
115
+ custom_urls = [
116
+ path(
117
+ "<path:object_id>/rerun/",
118
+ self.admin_site.admin_view(self.rerun_task_view),
119
+ name="celery_log_tasklog_rerun",
120
+ ),
121
+ ]
122
+ return custom_urls + super().get_urls()
123
+
124
+ def rerun_task_view(self, request, object_id):
125
+ task_log = self.get_object(request, object_id)
126
+ if not task_log:
127
+ messages.error(request, _("Task log not found."))
128
+ return HttpResponseRedirect(reverse("admin:celery_log_tasklog_changelist"))
129
+
130
+ try:
131
+ result = current_app.send_task(
132
+ task_log.task_name,
133
+ args=task_log.task_args or [],
134
+ kwargs=task_log.task_kwargs or {},
135
+ queue=task_log.queue_name,
136
+ )
137
+ messages.success(request, _("Task '%(task)s' has been re-queued with ID: %(task_id)s") % {
138
+ "task": task_log.task_name,
139
+ "task_id": result.id,
140
+ })
141
+ except Exception as exc:
142
+ messages.error(request, _("Failed to re-run task '%(task)s': %(error)s") % {
143
+ "task": task_log.task_name,
144
+ "error": exc,
145
+ })
146
+
147
+ return HttpResponseRedirect(reverse("admin:celery_log_tasklog_change", args=[object_id]))
148
+
149
+ def exc_type(self, obj):
150
+ return (obj.traceback or {}).get("exc_type", "")
151
+
152
+ exc_type.short_description = _("Exception Type")
153
+
154
+ def exc_msg(self, obj):
155
+ return (obj.traceback or {}).get("exc_msg", "")
156
+
157
+ exc_msg.short_description = _("Exception Message")
158
+
159
+ def result_pretty(self, obj):
160
+ data = obj.result or {}
161
+ safe = escape(json.dumps(data, indent=2, sort_keys=True, default=str))
162
+ return mark_safe(
163
+ '<pre style="background:#f7f7f7;padding:8px;border:1px solid #ddd;'
164
+ 'white-space:pre-wrap;word-break:break-all;max-height:500px;overflow:auto;">'
165
+ f"{safe}</pre>"
166
+ )
167
+
168
+ result_pretty.short_description = _("Result")
169
+
170
+ def result_snippet(self, obj):
171
+ data = obj.result or {}
172
+ if not data and obj.status == TaskLog.Status.FAILURE:
173
+ data = {"error": obj.error_message}
174
+
175
+ data = sort_dict_recursively(data)
176
+ compact = escape(json.dumps(data, separators=(",", ":"), default=str))
177
+ pretty = escape(json.dumps(data, indent=2, sort_keys=True, default=str))
178
+ return mark_safe(
179
+ '<details style="display:inline-block;max-width:500px;vertical-align:top;">'
180
+ '<summary style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;">'
181
+ f"{compact}</summary>"
182
+ '<pre style="background:#f7f7f7;padding:4px;border:1px solid #ddd;margin:4px 0;'
183
+ f'white-space:pre-wrap;word-wrap:break-word;max-height:500px;overflow:auto;">{pretty}</pre>'
184
+ "</details>"
185
+ )
186
+
187
+ result_snippet.short_description = _("Result")
188
+
189
+ def traceback_pretty(self, obj):
190
+ frames = (obj.traceback or {}).get("exc_tb", {}).get("frames", [])
191
+ html_chunks = []
192
+ total = len(frames)
193
+
194
+ for index, frame in enumerate(frames):
195
+ details_attr = " open" if index == total - 1 else ""
196
+ header = "Frame {index}: {func} @ {module}:{line}".format(
197
+ index=index,
198
+ func=frame.get("func_name"),
199
+ module=frame.get("module_name"),
200
+ line=frame.get("lineno"),
201
+ )
202
+ payload = escape(json.dumps(frame, indent=2, sort_keys=True, default=str))
203
+ html_chunks.append(
204
+ f'<details{details_attr} style="margin-bottom:8px;">'
205
+ f'<summary style="font-weight:bold;">{escape(header)}</summary>'
206
+ '<pre style="background:#f7f7f7;padding:8px;border:1px solid #ddd;'
207
+ f'white-space:pre-wrap;word-break:break-all;max-height:300px;overflow:auto;">{payload}</pre>'
208
+ "</details>"
209
+ )
210
+
211
+ return mark_safe("".join(html_chunks)) if html_chunks else _("No traceback available.")
212
+
213
+ traceback_pretty.short_description = _("Traceback details")
214
+
215
+ def _timestamp(self, obj):
216
+ return obj.timestamp.strftime("%Y-%m-%d %H:%M")
217
+
218
+ _timestamp.short_description = _("Timestamp")
219
+ _timestamp.admin_order_field = "timestamp"
220
+
221
+
222
+ @admin.register(TaskLogStatistics)
223
+ class TaskLogStatisticsAdmin(admin.ModelAdmin):
224
+ model = TaskLogStatistics
225
+
226
+ def has_add_permission(self, request):
227
+ return False
228
+
229
+ def has_change_permission(self, request, obj=None):
230
+ return False
231
+
232
+ def has_delete_permission(self, request, obj=None):
233
+ return False
234
+
235
+ def changelist_view(self, request, extra_context=None):
236
+ days_to_show = getattr(settings, "CELERY_TASK_LOGS_EXPIRES", 2)
237
+ end_date = timezone.now().date()
238
+ start_date = end_date - timedelta(days=days_to_show)
239
+ base_queryset = TaskLog.objects.filter(timestamp__date__gte=start_date, timestamp__date__lte=end_date)
240
+
241
+ messages_per_day = (
242
+ base_queryset.extra(select={"day": "date(timestamp)"})
243
+ .values("day")
244
+ .annotate(total=Count("id"))
245
+ .order_by("day")
246
+ )
247
+ failures_per_day = (
248
+ base_queryset.filter(status=TaskLog.Status.FAILURE)
249
+ .extra(select={"day": "date(timestamp)"})
250
+ .values("day")
251
+ .annotate(failures=Count("id"))
252
+ .order_by("day")
253
+ )
254
+ success_failure_stats = base_queryset.aggregate(
255
+ total_success=Count("id", filter=Q(status=TaskLog.Status.SUCCESS)),
256
+ total_failure=Count("id", filter=Q(status=TaskLog.Status.FAILURE)),
257
+ )
258
+ top_tasks = list(
259
+ base_queryset.values("task_name")
260
+ .annotate(
261
+ execution_count=Count("id"),
262
+ success_count=Count("id", filter=Q(status=TaskLog.Status.SUCCESS)),
263
+ failure_count=Count("id", filter=Q(status=TaskLog.Status.FAILURE)),
264
+ success_rate=Count("id", filter=Q(status=TaskLog.Status.SUCCESS)) * 100.0 / Count("id"),
265
+ avg_duration=Avg("duration_secs", filter=Q(duration_secs__gt=0)),
266
+ max_duration=Max("duration_secs", filter=Q(duration_secs__gt=0)),
267
+ )
268
+ .order_by("-execution_count")[:10]
269
+ )
270
+ top_queues = list(
271
+ base_queryset.values("queue_name")
272
+ .annotate(
273
+ message_count=Count("id"),
274
+ success_count=Count("id", filter=Q(status=TaskLog.Status.SUCCESS)),
275
+ failure_count=Count("id", filter=Q(status=TaskLog.Status.FAILURE)),
276
+ )
277
+ .order_by("-message_count")[:10]
278
+ )
279
+ worker_distribution = list(
280
+ base_queryset.values("worker")
281
+ .annotate(
282
+ task_count=Count("id"),
283
+ success_count=Count("id", filter=Q(status=TaskLog.Status.SUCCESS)),
284
+ failure_count=Count("id", filter=Q(status=TaskLog.Status.FAILURE)),
285
+ )
286
+ .order_by("-task_count")[:10]
287
+ )
288
+ periodic_task_distribution = list(
289
+ base_queryset.filter(periodic_task_name__isnull=False)
290
+ .values("periodic_task_name")
291
+ .annotate(
292
+ execution_count=Count("id"),
293
+ success_count=Count("id", filter=Q(status=TaskLog.Status.SUCCESS)),
294
+ failure_count=Count("id", filter=Q(status=TaskLog.Status.FAILURE)),
295
+ )
296
+ .order_by("-execution_count")[:10]
297
+ )
298
+ error_analysis = list(
299
+ base_queryset.filter(status=TaskLog.Status.FAILURE, error_message__isnull=False)
300
+ .exclude(error_message="")
301
+ .values("error_message")
302
+ .annotate(error_count=Count("id"))
303
+ .order_by("-error_count")[:10]
304
+ )
305
+ slowest_tasks = list(
306
+ base_queryset.filter(duration_secs__gt=0)
307
+ .values("task_name")
308
+ .annotate(message_count=Count("id"), avg_duration=Avg("duration_secs"), max_duration=Max("duration_secs"))
309
+ .order_by("-avg_duration", "-max_duration")[:10]
310
+ )
311
+ performance_stats = base_queryset.filter(duration_secs__gt=0).aggregate(
312
+ avg_duration=Avg("duration_secs"),
313
+ min_duration=Min("duration_secs"),
314
+ max_duration=Max("duration_secs"),
315
+ total_with_duration=Count("id"),
316
+ )
317
+
318
+ for task in top_tasks + slowest_tasks:
319
+ task["avg_duration"] = int(round(task.get("avg_duration") or 0))
320
+
321
+ chart_data = {
322
+ "messages_per_day": list(messages_per_day),
323
+ "failures_per_day": list(failures_per_day),
324
+ "success_failure_stats": success_failure_stats,
325
+ "top_queues": top_queues,
326
+ "top_tasks": top_tasks,
327
+ "worker_distribution": worker_distribution,
328
+ "periodic_task_distribution": periodic_task_distribution,
329
+ "error_analysis": error_analysis,
330
+ "slowest_tasks": slowest_tasks,
331
+ "date_range": {"start": start_date.isoformat(), "end": end_date.isoformat()},
332
+ }
333
+
334
+ total_messages = success_failure_stats["total_success"] + success_failure_stats["total_failure"]
335
+ days = max((end_date - start_date).days + 1, 1)
336
+ extra_context = extra_context or {}
337
+ extra_context.update(
338
+ {
339
+ "chart_data": json.dumps(chart_data, default=str),
340
+ "days_to_show": days_to_show,
341
+ "top_tasks": top_tasks,
342
+ "slowest_tasks": slowest_tasks,
343
+ "statistics": {
344
+ "total_messages": total_messages,
345
+ "total_success": success_failure_stats["total_success"],
346
+ "total_failure": success_failure_stats["total_failure"],
347
+ "success_rate": round((success_failure_stats["total_success"] / max(total_messages, 1)) * 100, 2),
348
+ "avg_messages_per_day": int(round(total_messages / days)),
349
+ "avg_messages_per_hour": int(round(total_messages / max(days * 24, 1))),
350
+ "avg_messages_per_minute": int(round(total_messages / max(days * 24 * 60, 1))),
351
+ "avg_duration": int(round(performance_stats["avg_duration"] or 0)),
352
+ "max_duration": performance_stats["max_duration"] or 0,
353
+ "min_duration": performance_stats["min_duration"] or 0,
354
+ "total_with_duration": performance_stats["total_with_duration"],
355
+ },
356
+ }
357
+ )
358
+ return super().changelist_view(request, extra_context=extra_context)
@@ -0,0 +1,12 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class CeleryLogsConfig(AppConfig):
6
+ default_auto_field = "django.db.models.BigAutoField"
7
+ name = "django_celery_logs"
8
+ label = "celery_log"
9
+ verbose_name = _("Celery Task Logs")
10
+
11
+ def ready(self):
12
+ from . import signals # noqa: F401
@@ -0,0 +1,65 @@
1
+ import django_celery_logs.models
2
+ from django.db import migrations, models
3
+
4
+
5
+ class Migration(migrations.Migration):
6
+ initial = True
7
+
8
+ dependencies = []
9
+
10
+ operations = [
11
+ migrations.CreateModel(
12
+ name="TaskConfig",
13
+ fields=[
14
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
15
+ (
16
+ "logs_expiration_days",
17
+ models.PositiveIntegerField(
18
+ default=django_celery_logs.models.get_default_logs_expiration_days,
19
+ help_text="Number of days to keep celery task logs.",
20
+ verbose_name="Logs Expiration Days",
21
+ ),
22
+ ),
23
+ ],
24
+ options={
25
+ "verbose_name": "Task Configuration",
26
+ "verbose_name_plural": "Task Configurations",
27
+ },
28
+ ),
29
+ migrations.CreateModel(
30
+ name="TaskLog",
31
+ fields=[
32
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
33
+ ("task_id", models.CharField(max_length=255, unique=True)),
34
+ ("task_name", models.CharField(max_length=255)),
35
+ ("periodic_task_name", models.CharField(blank=True, max_length=255, null=True)),
36
+ ("queue_name", models.CharField(blank=True, max_length=255, null=True)),
37
+ ("worker", models.CharField(max_length=255)),
38
+ ("status", models.CharField(choices=[("SUCCESS", "Success"), ("FAILURE", "Failure")], max_length=10)),
39
+ ("task_args", models.JSONField(blank=True, null=True)),
40
+ ("task_kwargs", models.JSONField(blank=True, null=True)),
41
+ ("result", models.JSONField(blank=True, null=True)),
42
+ ("duration_secs", models.FloatField(blank=True, null=True, verbose_name="Duration (Seconds)")),
43
+ ("error_message", models.TextField(blank=True)),
44
+ ("traceback", models.JSONField(blank=True, null=True)),
45
+ ("timestamp", models.DateTimeField(auto_now_add=True)),
46
+ ],
47
+ options={
48
+ "verbose_name": "Task Log",
49
+ "verbose_name_plural": "Task Logs",
50
+ "ordering": ["-timestamp"],
51
+ },
52
+ ),
53
+ migrations.CreateModel(
54
+ name="TaskLogStatistics",
55
+ fields=[],
56
+ options={
57
+ "verbose_name": "Task Log Statistics",
58
+ "verbose_name_plural": "Task Log Statistics",
59
+ "proxy": True,
60
+ "indexes": [],
61
+ "constraints": [],
62
+ },
63
+ bases=("celery_log.tasklog",),
64
+ ),
65
+ ]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,62 @@
1
+ from django.conf import settings
2
+ from django.db import models
3
+ from django.utils.translation import gettext_lazy as _
4
+
5
+
6
+ def get_default_logs_expiration_days():
7
+ return getattr(settings, "CELERY_TASK_LOGS_EXPIRES", 2)
8
+
9
+
10
+ class TaskConfig(models.Model):
11
+ logs_expiration_days = models.PositiveIntegerField(
12
+ default=get_default_logs_expiration_days,
13
+ verbose_name=_("Logs Expiration Days"),
14
+ help_text=_("Number of days to keep celery task logs."),
15
+ )
16
+
17
+ class Meta:
18
+ verbose_name = _("Task Configuration")
19
+ verbose_name_plural = _("Task Configurations")
20
+
21
+ def __str__(self):
22
+ return str(_("Task Configuration"))
23
+
24
+ @classmethod
25
+ def get_solo(cls):
26
+ obj, _created = cls.objects.get_or_create(pk=1)
27
+ return obj
28
+
29
+
30
+ class TaskLog(models.Model):
31
+ class Status(models.TextChoices):
32
+ SUCCESS = "SUCCESS", _("Success")
33
+ FAILURE = "FAILURE", _("Failure")
34
+
35
+ task_id = models.CharField(max_length=255, unique=True)
36
+ task_name = models.CharField(max_length=255)
37
+ periodic_task_name = models.CharField(max_length=255, null=True, blank=True)
38
+ queue_name = models.CharField(max_length=255, null=True, blank=True)
39
+ worker = models.CharField(max_length=255)
40
+ status = models.CharField(max_length=10, choices=Status.choices)
41
+ task_args = models.JSONField(null=True, blank=True)
42
+ task_kwargs = models.JSONField(null=True, blank=True)
43
+ result = models.JSONField(null=True, blank=True)
44
+ duration_secs = models.FloatField(null=True, blank=True, verbose_name=_("Duration (Seconds)"))
45
+ error_message = models.TextField(blank=True)
46
+ traceback = models.JSONField(blank=True, null=True)
47
+ timestamp = models.DateTimeField(auto_now_add=True)
48
+
49
+ class Meta:
50
+ ordering = ["-timestamp"]
51
+ verbose_name = _("Task Log")
52
+ verbose_name_plural = _("Task Logs")
53
+
54
+ def __str__(self):
55
+ return f"{self.task_id} ({self.task_name}) -> {self.status}"
56
+
57
+
58
+ class TaskLogStatistics(TaskLog):
59
+ class Meta:
60
+ proxy = True
61
+ verbose_name = _("Task Log Statistics")
62
+ verbose_name_plural = _("Task Log Statistics")
@@ -0,0 +1,13 @@
1
+ from celery.signals import task_failure, task_success
2
+
3
+ from .utils import create_task_log
4
+
5
+
6
+ @task_success.connect
7
+ def log_task_success(sender=None, result=None, **kwargs):
8
+ create_task_log(sender=sender, result=result)
9
+
10
+
11
+ @task_failure.connect
12
+ def log_task_failure(sender=None, exception=None, **kwargs):
13
+ create_task_log(sender=sender, exception=exception)
@@ -0,0 +1,7 @@
1
+ #changelist-filter.hidden {
2
+ display: none !important;
3
+ }
4
+
5
+ .filtered.hidden-filters #changelist {
6
+ margin-right: 0 !important;
7
+ }
@@ -0,0 +1,35 @@
1
+ document.addEventListener("DOMContentLoaded", function () {
2
+ const toggleBtn = document.createElement("button");
3
+ const filterDiv = document.getElementById("changelist-filter");
4
+
5
+ if (!filterDiv) return;
6
+
7
+ const i18n = window.adminFiltersI18n || {
8
+ showFilters: "Show Filters",
9
+ hideFilters: "Hide Filters"
10
+ };
11
+
12
+ toggleBtn.className = "historylink";
13
+ toggleBtn.textContent = i18n.showFilters;
14
+ toggleBtn.type = "button";
15
+
16
+ filterDiv.classList.add("hidden");
17
+ document.body.classList.add("hidden-filters");
18
+ filterDiv.parentNode.insertBefore(toggleBtn, filterDiv);
19
+
20
+ toggleBtn.addEventListener("click", function () {
21
+ const currentlyHidden = filterDiv.classList.contains("hidden");
22
+
23
+ if (currentlyHidden) {
24
+ filterDiv.classList.remove("hidden");
25
+ document.body.classList.remove("hidden-filters");
26
+ toggleBtn.textContent = i18n.hideFilters;
27
+ localStorage.setItem("admin-filters-hidden", "false");
28
+ } else {
29
+ filterDiv.classList.add("hidden");
30
+ document.body.classList.add("hidden-filters");
31
+ toggleBtn.textContent = i18n.showFilters;
32
+ localStorage.setItem("admin-filters-hidden", "true");
33
+ }
34
+ });
35
+ });
@@ -0,0 +1,45 @@
1
+ import time
2
+ from datetime import timedelta
3
+
4
+ from celery import shared_task
5
+ from celery.utils.log import get_task_logger
6
+ from django.apps import apps as django_apps
7
+ from django.db import transaction
8
+ from django.utils import timezone
9
+
10
+ logger = get_task_logger(__name__)
11
+
12
+
13
+ @shared_task(name="clear_celery_task_logs")
14
+ def clear_celery_task_logs():
15
+ start_time = time.perf_counter()
16
+ logger.debug("Starting clear_celery_task_logs")
17
+
18
+ TaskLog = django_apps.get_model("celery_log", "TaskLog")
19
+ TaskConfig = django_apps.get_model("celery_log", "TaskConfig")
20
+ config = TaskConfig.get_solo()
21
+ cutoff = timezone.now() - timedelta(days=config.logs_expiration_days)
22
+
23
+ total_deleted = 0
24
+ batch_size = 1000
25
+
26
+ try:
27
+ with transaction.atomic():
28
+ queryset = TaskLog.objects.filter(timestamp__lte=cutoff)
29
+ while True:
30
+ batch_ids = list(queryset.values_list("id", flat=True)[:batch_size])
31
+ if not batch_ids:
32
+ break
33
+ deleted, _details = TaskLog.objects.filter(id__in=batch_ids).delete()
34
+ total_deleted += deleted
35
+ except Exception:
36
+ logger.exception("clear_celery_task_logs failed during batch delete")
37
+
38
+ duration = time.perf_counter() - start_time
39
+ logger.info("clear_celery_task_logs completed: removed %s entries in %.2fs", total_deleted, duration)
40
+
41
+ return {
42
+ "status": "Celery task logs cleaned",
43
+ "deleted": total_deleted,
44
+ "duration_s": round(duration, 2),
45
+ }
@@ -0,0 +1,21 @@
1
+ {% extends "admin/change_form.html" %}
2
+ {% load i18n admin_urls %}
3
+
4
+ {% block object-tools %}
5
+ {% if change %}{% if not is_popup %}
6
+ <ul class="object-tools">
7
+ {% block object-tools-items %}
8
+ {% if original %}
9
+ <li>
10
+ <a href="{% url 'admin:celery_log_tasklog_rerun' original.pk %}"
11
+ class="historylink"
12
+ onclick="return confirm('{% trans "Are you sure you want to re-run this task?" %}');">
13
+ {% trans "Re-run Task" %}
14
+ </a>
15
+ </li>
16
+ {% endif %}
17
+ {{ block.super }}
18
+ {% endblock %}
19
+ </ul>
20
+ {% endif %}{% endif %}
21
+ {% endblock %}