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.
- django_celery_logs/__init__.py +1 -0
- django_celery_logs/admin.py +358 -0
- django_celery_logs/apps.py +12 -0
- django_celery_logs/migrations/0001_initial.py +65 -0
- django_celery_logs/migrations/__init__.py +1 -0
- django_celery_logs/models.py +62 -0
- django_celery_logs/signals.py +13 -0
- django_celery_logs/static/django_celery_logs/css/admin_filters.css +7 -0
- django_celery_logs/static/django_celery_logs/js/admin_filters.js +35 -0
- django_celery_logs/tasks.py +45 -0
- django_celery_logs/templates/admin/celery_log/tasklog/change_form.html +21 -0
- django_celery_logs/templates/admin/celery_log/tasklog/change_list.html +214 -0
- django_celery_logs/templates/admin/celery_log/tasklogstatistics/change_list.html +325 -0
- django_celery_logs/templatetags/__init__.py +1 -0
- django_celery_logs/templatetags/celery_logs_admin.py +20 -0
- django_celery_logs/utils.py +173 -0
- django_celery_logs/widgets.py +16 -0
- django_celery_logs-0.2.0.dist-info/METADATA +380 -0
- django_celery_logs-0.2.0.dist-info/RECORD +22 -0
- django_celery_logs-0.2.0.dist-info/WHEEL +5 -0
- django_celery_logs-0.2.0.dist-info/licenses/LICENSE +21 -0
- django_celery_logs-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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,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 %}
|