django-admin-background-task 0.1.0__py3-none-any.whl → 0.3.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.
- bgtask/admin.py +6 -4
- bgtask/backends/__init__.py +3 -0
- bgtask/backends/thread_pool.py +12 -0
- bgtask/decorators.py +46 -0
- bgtask/migrations/0002_backgroundtask_namespace_alter_backgroundtask_name.py +31 -0
- bgtask/model_admin.py +113 -0
- bgtask/models.py +70 -21
- bgtask/static/bgtask/js/bgtask.js +98 -31
- bgtask/templates/bgtask/admin/change_list.html +31 -0
- bgtask/templates/bgtask/bg_changelist_status_column.html +1 -1
- bgtask/templates/bgtask/bg_completed_column.html +24 -0
- bgtask/views.py +1 -8
- {django_admin_background_task-0.1.0.dist-info → django_admin_background_task-0.3.0.dist-info}/METADATA +2 -2
- django_admin_background_task-0.3.0.dist-info/RECORD +29 -0
- bgtask/.DS_Store +0 -0
- django_admin_background_task-0.1.0.dist-info/RECORD +0 -23
- {django_admin_background_task-0.1.0.dist-info → django_admin_background_task-0.3.0.dist-info}/LICENSE +0 -0
- {django_admin_background_task-0.1.0.dist-info → django_admin_background_task-0.3.0.dist-info}/WHEEL +0 -0
bgtask/admin.py
CHANGED
@@ -2,7 +2,6 @@ from django.contrib import admin
|
|
2
2
|
from django.template.loader import render_to_string
|
3
3
|
|
4
4
|
from .models import BackgroundTask
|
5
|
-
from .views import task_dict
|
6
5
|
|
7
6
|
|
8
7
|
def background_task_status(obj):
|
@@ -15,7 +14,7 @@ def background_task_status(obj):
|
|
15
14
|
bgtask = bgtasks.first()
|
16
15
|
|
17
16
|
output = render_to_string(
|
18
|
-
"bgtask/bg_changelist_status_column.html", {"bgtask": bgtask and task_dict
|
17
|
+
"bgtask/bg_changelist_status_column.html", {"bgtask": bgtask and bgtask.task_dict}
|
19
18
|
)
|
20
19
|
return output
|
21
20
|
|
@@ -25,6 +24,9 @@ background_task_status.__name__ = "Task Status"
|
|
25
24
|
|
26
25
|
@admin.register(BackgroundTask)
|
27
26
|
class BackgroundTaskAdmin(admin.ModelAdmin):
|
28
|
-
list_filter = ["state", "
|
29
|
-
list_display = ("created", background_task_status, "result", "completed_at")
|
27
|
+
list_filter = ["state", "namespace", "name"]
|
28
|
+
list_display = ("created", "namespace_name", background_task_status, "result", "completed_at")
|
30
29
|
ordering = ["-created"]
|
30
|
+
|
31
|
+
def namespace_name(self, bgtask):
|
32
|
+
return ".".join(f for f in [bgtask.namespace, bgtask.name] if f)
|
@@ -0,0 +1,12 @@
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
2
|
+
|
3
|
+
|
4
|
+
SHARED_THREAD_POOL = None
|
5
|
+
|
6
|
+
|
7
|
+
def dispatch(func, bg_task, *args, **kwargs):
|
8
|
+
global SHARED_THREAD_POOL
|
9
|
+
if SHARED_THREAD_POOL is None:
|
10
|
+
SHARED_THREAD_POOL = ThreadPoolExecutor()
|
11
|
+
|
12
|
+
SHARED_THREAD_POOL.submit(func, bg_task, *args, **kwargs)
|
bgtask/decorators.py
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
import logging
|
2
|
+
from functools import wraps
|
3
|
+
|
4
|
+
from django.contrib.messages import INFO
|
5
|
+
|
6
|
+
|
7
|
+
from .models import BackgroundTask
|
8
|
+
|
9
|
+
|
10
|
+
log = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
def bgtask_admin_action(func=None):
|
14
|
+
if func is not None:
|
15
|
+
return bgtask_admin_action()(func)
|
16
|
+
|
17
|
+
def bgtask_admin_action_factory(func):
|
18
|
+
|
19
|
+
task_name = f"AdminTask-{func.__name__}"
|
20
|
+
|
21
|
+
@wraps(func)
|
22
|
+
def bgtask_admin_action_wrapper(self, request, queryset):
|
23
|
+
log.info("Running func %s", func.__name__)
|
24
|
+
bg_task = self.start_bgtask(task_name)
|
25
|
+
|
26
|
+
self.message_user(request, "Started background task", level=INFO)
|
27
|
+
|
28
|
+
from .backends import default_backend
|
29
|
+
|
30
|
+
default_backend.dispatch(_run_bg_task_func, func, bg_task, request, queryset)
|
31
|
+
|
32
|
+
|
33
|
+
bgtask_admin_action_wrapper.bgtask_name = task_name
|
34
|
+
|
35
|
+
return bgtask_admin_action_wrapper
|
36
|
+
|
37
|
+
return bgtask_admin_action_factory
|
38
|
+
|
39
|
+
|
40
|
+
def _run_bg_task_func(func, bg_task, request, queryset):
|
41
|
+
try:
|
42
|
+
func(bg_task, request, queryset)
|
43
|
+
except Exception as exc:
|
44
|
+
bg_task.fail(exc)
|
45
|
+
else:
|
46
|
+
bg_task.succeed()
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# Generated by Django 4.2.11 on 2024-04-16 14:59
|
2
|
+
|
3
|
+
from django.db import migrations, models
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
("bgtask", "0001_initial"),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
migrations.AddField(
|
14
|
+
model_name="backgroundtask",
|
15
|
+
name="namespace",
|
16
|
+
field=models.CharField(
|
17
|
+
blank=True,
|
18
|
+
default="",
|
19
|
+
help_text="Optional namespace that can be used to avoid having to make names unique across an entire codebase, allowing them to be shorter and human readable",
|
20
|
+
max_length=1000,
|
21
|
+
),
|
22
|
+
),
|
23
|
+
migrations.AlterField(
|
24
|
+
model_name="backgroundtask",
|
25
|
+
name="name",
|
26
|
+
field=models.CharField(
|
27
|
+
help_text="Name (or type) of this task, is not unique per task instance but generally per task functionality",
|
28
|
+
max_length=1000,
|
29
|
+
),
|
30
|
+
),
|
31
|
+
]
|
bgtask/model_admin.py
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
from datetime import timedelta
|
2
|
+
from functools import wraps
|
3
|
+
|
4
|
+
from django.contrib import admin
|
5
|
+
from django.contrib.admin.utils import label_for_field
|
6
|
+
from django.contrib.messages import INFO
|
7
|
+
from django.db.models import Q
|
8
|
+
from django.utils import timezone
|
9
|
+
|
10
|
+
from .models import BackgroundTask
|
11
|
+
|
12
|
+
|
13
|
+
class BGTaskModelAdmin(admin.ModelAdmin):
|
14
|
+
# This is not overridden to avoid messing with the implicit logic for finding change list
|
15
|
+
# templates that ModelAdmin uses. So you either need to specify this yourself on your
|
16
|
+
# subclass or you need to extend from this in your custom template.
|
17
|
+
#
|
18
|
+
# change_list_template = "bgtask/admin/change_list.html"
|
19
|
+
|
20
|
+
# ----------------------------------------------------------------------------------------------
|
21
|
+
# Class API
|
22
|
+
# ----------------------------------------------------------------------------------------------
|
23
|
+
@classmethod
|
24
|
+
def starts_task(cls, name, **task_kwargs):
|
25
|
+
|
26
|
+
def starts_task_decorator(func):
|
27
|
+
|
28
|
+
@wraps(func)
|
29
|
+
def starts_task_wrapper(self, request, *args, **kwargs):
|
30
|
+
bgtask = self.start_bgtask(name, **task_kwargs)
|
31
|
+
result = func(self, request, *args, bgtask=bgtask, **kwargs)
|
32
|
+
self.message_user(request, f"Dispatched task {name}", INFO)
|
33
|
+
|
34
|
+
func.bgtask_name = name
|
35
|
+
|
36
|
+
return starts_task_wrapper
|
37
|
+
|
38
|
+
return starts_task_decorator
|
39
|
+
|
40
|
+
# ----------------------------------------------------------------------------------------------
|
41
|
+
# API for subclasses
|
42
|
+
# ----------------------------------------------------------------------------------------------
|
43
|
+
def start_bgtask(self, name, **kwargs):
|
44
|
+
bgtask = BackgroundTask.objects.create(
|
45
|
+
name=name,
|
46
|
+
namespace=self._bgtask_namespace,
|
47
|
+
**kwargs,
|
48
|
+
)
|
49
|
+
bgtask.start()
|
50
|
+
return bgtask
|
51
|
+
|
52
|
+
# ----------------------------------------------------------------------------------------------
|
53
|
+
# Superclass overrides
|
54
|
+
# ----------------------------------------------------------------------------------------------
|
55
|
+
def changelist_view(self, request, extra_context=None):
|
56
|
+
extra_context = extra_context or {}
|
57
|
+
extra_context["admin_bg_tasks"] = self._admin_bg_tasks(request)
|
58
|
+
return super().changelist_view(request, extra_context=extra_context)
|
59
|
+
|
60
|
+
# ----------------------------------------------------------------------------------------------
|
61
|
+
# Internal functions
|
62
|
+
# ----------------------------------------------------------------------------------------------
|
63
|
+
@property
|
64
|
+
def _bgtask_namespace(self):
|
65
|
+
return type(self).__module__ + "." + type(self).__name__
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def _extract_bgtask_name_from_admin_action(action):
|
69
|
+
# recurse through the potentially wrapped action until we find one that declares
|
70
|
+
# the bgtask_name
|
71
|
+
next_action = action
|
72
|
+
while True:
|
73
|
+
if hasattr(next_action, "bgtask_name"):
|
74
|
+
return next_action.bgtask_name
|
75
|
+
|
76
|
+
if not hasattr(next_action, "__wrapped__"):
|
77
|
+
return None
|
78
|
+
|
79
|
+
next_action = next_action.__wrapped__
|
80
|
+
|
81
|
+
def _admin_bg_tasks(self, request):
|
82
|
+
task_name_to_desc = {}
|
83
|
+
for action, action_name, action_description in self.get_actions(request).values():
|
84
|
+
bgtask_name = self._extract_bgtask_name_from_admin_action(action)
|
85
|
+
if bgtask_name is not None:
|
86
|
+
task_name_to_desc[bgtask_name] = action_description
|
87
|
+
|
88
|
+
for name in getattr(self, "bgtask_names", []):
|
89
|
+
task_name_to_desc[name] = name
|
90
|
+
|
91
|
+
if not task_name_to_desc:
|
92
|
+
return BackgroundTask.objects.none()
|
93
|
+
|
94
|
+
bgts = list(
|
95
|
+
BackgroundTask.objects.filter(
|
96
|
+
name__in=task_name_to_desc, namespace=self._bgtask_namespace
|
97
|
+
)
|
98
|
+
.filter(
|
99
|
+
(
|
100
|
+
Q(state=BackgroundTask.STATES.running)
|
101
|
+
& Q(started_at__gt=timezone.now() - timedelta(days=1))
|
102
|
+
)
|
103
|
+
| (
|
104
|
+
~Q(state=BackgroundTask.STATES.not_started)
|
105
|
+
& Q(completed_at__gt=timezone.now() - timedelta(hours=2))
|
106
|
+
)
|
107
|
+
)
|
108
|
+
.order_by("-started_at")
|
109
|
+
)
|
110
|
+
for bgt in bgts:
|
111
|
+
bgt.admin_description = task_name_to_desc[bgt.name]
|
112
|
+
|
113
|
+
return bgts
|
bgtask/models.py
CHANGED
@@ -4,10 +4,12 @@ import os
|
|
4
4
|
import time
|
5
5
|
import traceback
|
6
6
|
import uuid
|
7
|
+
from contextlib import contextmanager
|
7
8
|
|
8
9
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
9
10
|
from django.contrib.contenttypes.models import ContentType
|
10
11
|
from django.db import models, transaction
|
12
|
+
from django.forms.models import model_to_dict
|
11
13
|
from django.utils import timezone
|
12
14
|
|
13
15
|
from model_utils import Choices
|
@@ -19,9 +21,19 @@ log = logging.getLogger(__name__)
|
|
19
21
|
def locked(meth):
|
20
22
|
@functools.wraps(meth)
|
21
23
|
def _locked_meth(self, *args, **kwargs):
|
24
|
+
if getattr(self, "_locked", False):
|
25
|
+
return meth(self, *args, **kwargs)
|
26
|
+
|
22
27
|
with transaction.atomic():
|
23
28
|
BackgroundTask.objects.filter(id=self.id).select_for_update().only("id").get()
|
24
|
-
|
29
|
+
self.refresh_from_db()
|
30
|
+
|
31
|
+
# Mark as locked in case we are called recursively
|
32
|
+
self._locked = True
|
33
|
+
try:
|
34
|
+
return meth(self, *args, **kwargs)
|
35
|
+
finally:
|
36
|
+
self._locked = False
|
25
37
|
|
26
38
|
return _locked_meth
|
27
39
|
|
@@ -41,14 +53,24 @@ def only_if_state(state):
|
|
41
53
|
return only_if_state_decorator
|
42
54
|
|
43
55
|
|
44
|
-
class
|
45
|
-
class Meta:
|
46
|
-
abstract = True
|
47
|
-
|
48
|
-
|
49
|
-
class BackgroundTask(CreatedUpdatedMixin):
|
56
|
+
class BackgroundTask(models.Model):
|
50
57
|
id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4)
|
51
|
-
name = models.CharField(
|
58
|
+
name = models.CharField(
|
59
|
+
max_length=1000,
|
60
|
+
help_text=(
|
61
|
+
"Name (or type) of this task, is not unique "
|
62
|
+
"per task instance but generally per task functionality"
|
63
|
+
),
|
64
|
+
)
|
65
|
+
namespace = models.CharField(
|
66
|
+
max_length=1000,
|
67
|
+
default="",
|
68
|
+
blank=True,
|
69
|
+
help_text=(
|
70
|
+
"Optional namespace that can be used to avoid having to make names unique across an "
|
71
|
+
"entire codebase, allowing them to be shorter and human readable"
|
72
|
+
),
|
73
|
+
)
|
52
74
|
|
53
75
|
STATES = Choices("not_started", "running", "success", "partial_success", "failed")
|
54
76
|
state = models.CharField(max_length=16, default=STATES.not_started, choices=STATES)
|
@@ -79,6 +101,11 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
79
101
|
class Meta:
|
80
102
|
ordering = ["created", "id"]
|
81
103
|
|
104
|
+
@property
|
105
|
+
def task_dict(self):
|
106
|
+
task_dict = model_to_dict(self)
|
107
|
+
return {"id": str(self.id), "updated": self.updated.isoformat(), **task_dict}
|
108
|
+
|
82
109
|
@property
|
83
110
|
def num_failed_steps(self):
|
84
111
|
return sum(error.get("num_failed_steps", 0) for error in self.errors)
|
@@ -87,6 +114,24 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
87
114
|
def incomplete(self):
|
88
115
|
return self.state in [self.STATES.not_started, self.STATES.running]
|
89
116
|
|
117
|
+
@contextmanager
|
118
|
+
def runs_single_step(self):
|
119
|
+
try:
|
120
|
+
yield
|
121
|
+
except Exception as exc:
|
122
|
+
self.steps_failed(1, error=exc)
|
123
|
+
else:
|
124
|
+
self.add_successful_steps(1)
|
125
|
+
|
126
|
+
@contextmanager
|
127
|
+
def finishes(self):
|
128
|
+
try:
|
129
|
+
yield
|
130
|
+
except Exception as exc:
|
131
|
+
self.fail(exc)
|
132
|
+
else:
|
133
|
+
self.succeed()
|
134
|
+
|
90
135
|
@locked
|
91
136
|
@only_if_state(STATES.not_started)
|
92
137
|
def start(self):
|
@@ -109,10 +154,10 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
109
154
|
|
110
155
|
@locked
|
111
156
|
@only_if_state(STATES.running)
|
112
|
-
def succeed(self, result):
|
157
|
+
def succeed(self, result=None):
|
113
158
|
log.info("%s succeeded.", self)
|
114
159
|
self.state = self.STATES.success
|
115
|
-
self.
|
160
|
+
self.steps_completed = self.steps_to_complete
|
116
161
|
self.completed_at = timezone.now()
|
117
162
|
self.result = self.serialize_result(result)
|
118
163
|
self.save()
|
@@ -125,8 +170,8 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
125
170
|
log.info("Finishing as success with no errors")
|
126
171
|
self.state = self.STATES.success
|
127
172
|
elif self.steps_to_complete is None:
|
128
|
-
log.info("Finishing as
|
129
|
-
self.state = self.STATES.
|
173
|
+
log.info("Finishing as success with no steps to complete configured")
|
174
|
+
self.state = self.STATES.success
|
130
175
|
elif self.num_failed_steps == self.steps_to_complete:
|
131
176
|
log.info("Finishing as failure with all steps failed")
|
132
177
|
self.state = self.STATES.failed
|
@@ -134,11 +179,13 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
134
179
|
log.info("Finishing as partial success with some steps failed")
|
135
180
|
self.state = self.STATES.partial_success
|
136
181
|
|
182
|
+
self.completed_at = timezone.now()
|
183
|
+
self.save()
|
184
|
+
|
137
185
|
@locked
|
138
186
|
def add_successful_steps(self, num_steps):
|
139
187
|
self.steps_completed += num_steps
|
140
|
-
self.
|
141
|
-
self.save()
|
188
|
+
self._finish_or_save()
|
142
189
|
|
143
190
|
@locked
|
144
191
|
def steps_failed(self, num_steps, steps_identifier=None, error=None):
|
@@ -153,8 +200,7 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
153
200
|
error_dict.update(self._error_dict_for_error(error))
|
154
201
|
|
155
202
|
self.errors.append(error_dict)
|
156
|
-
self.
|
157
|
-
self.save()
|
203
|
+
self._finish_or_save()
|
158
204
|
|
159
205
|
def dispatch(self):
|
160
206
|
# double fork to avoid zombies
|
@@ -227,9 +273,12 @@ class BackgroundTask(CreatedUpdatedMixin):
|
|
227
273
|
)
|
228
274
|
return error_dict
|
229
275
|
|
230
|
-
def
|
231
|
-
if
|
232
|
-
|
233
|
-
|
234
|
-
|
276
|
+
def _finish_or_save(self):
|
277
|
+
if (
|
278
|
+
self.steps_to_complete is not None
|
279
|
+
and self.steps_completed is not None
|
280
|
+
and self.steps_completed >= self.steps_to_complete
|
281
|
+
):
|
235
282
|
self.finish()
|
283
|
+
else:
|
284
|
+
self.save()
|
@@ -14,6 +14,33 @@ const TIME_TO_REDUCED_REFRESH_PERIOD_S = 300;
|
|
14
14
|
const REDUCED_REFRESH_PERIOD_S = 60;
|
15
15
|
const PROGRESS_REFRESH_MS = 100;
|
16
16
|
|
17
|
+
function millisecondsToTimeAgoString(ms) {
|
18
|
+
const seconds = Math.floor(ms / 1000);
|
19
|
+
const minutes = Math.floor(seconds / 60);
|
20
|
+
const hours = Math.floor(minutes / 60);
|
21
|
+
const days = Math.floor(hours / 24);
|
22
|
+
|
23
|
+
if (days > 0) {
|
24
|
+
if (days === 1) {
|
25
|
+
return `${days} day, ${hours % 24} hours ago`;
|
26
|
+
}
|
27
|
+
return `${days} days ago`;
|
28
|
+
}
|
29
|
+
if (hours > 0) {
|
30
|
+
if (hours === 1) {
|
31
|
+
return `${hours} hour, ${minutes % 60} minutes ago`;
|
32
|
+
}
|
33
|
+
return `${hours} hours ago`;
|
34
|
+
}
|
35
|
+
if (minutes > 0) {
|
36
|
+
if (minutes === 1) {
|
37
|
+
return `${minutes} minute, ${seconds % 60} seconds ago`;
|
38
|
+
}
|
39
|
+
return `${minutes} minutes ago`;
|
40
|
+
}
|
41
|
+
return `${seconds} seconds ago`;
|
42
|
+
}
|
43
|
+
|
17
44
|
// -------------------------------------------------------------------------------------------------
|
18
45
|
// Generic live-looking progress bar manager.
|
19
46
|
// -------------------------------------------------------------------------------------------------
|
@@ -21,7 +48,7 @@ class ProgressState {
|
|
21
48
|
constructor(element, {value, max} = {value: null, max: null}) {
|
22
49
|
this.element = element;
|
23
50
|
|
24
|
-
this.
|
51
|
+
this.animationFrameRequestId = null;
|
25
52
|
|
26
53
|
this.max = max;
|
27
54
|
// set it immediately initially
|
@@ -56,56 +83,60 @@ class ProgressState {
|
|
56
83
|
this._cancelTimer();
|
57
84
|
const currentValue = this.progEle.value || 0;
|
58
85
|
if (value === null || value === undefined) {
|
86
|
+
// console.info("null value");
|
59
87
|
this.progEle.removeAttribute("value");
|
60
|
-
this._clearState();
|
61
88
|
return
|
62
89
|
}
|
63
90
|
if (value >= this.max) {
|
91
|
+
// console.info("greater than max", value, this.max);
|
64
92
|
this.progEle.value = this.max;
|
65
|
-
this._clearState();
|
66
93
|
return;
|
67
94
|
}
|
68
95
|
if (currentValue === value) {
|
69
|
-
|
96
|
+
// console.info("already at value", currentValue, value);
|
70
97
|
return;
|
71
98
|
}
|
72
99
|
|
73
100
|
// completion due in the refresh period plus a bit so that we don't get there too soon
|
74
101
|
// and cause a visible stop: we would rather the next update arrived before we reached the
|
75
102
|
// target value
|
76
|
-
const
|
77
|
-
const
|
103
|
+
const now = new Date();
|
104
|
+
const completionDue = new Date(now.getTime() + REFRESH_PERIOD_MS + PROGRESS_REFRESH_MS);
|
105
|
+
const initialMSToCompletion = completionDue - now;
|
106
|
+
const completionDueHighRes = performance.now() + initialMSToCompletion;
|
78
107
|
const previousValue = currentValue;
|
79
108
|
|
80
|
-
|
81
|
-
const now = new Date();
|
82
|
-
if (completionDue <= now) {
|
83
|
-
this.progEle.value = value;
|
84
|
-
this._cancelTimer();
|
85
|
-
this._clearState();
|
86
|
-
return;
|
87
|
-
}
|
109
|
+
let startTimestamp = null;
|
88
110
|
|
89
|
-
|
111
|
+
const updateProgressStep = (now) => {
|
112
|
+
if (startTimestamp === null) {
|
113
|
+
startTimestamp = now;
|
114
|
+
this.animationFrameRequestId = window.requestAnimationFrame(updateProgressStep);
|
115
|
+
return;
|
116
|
+
}
|
117
|
+
const msRemaining = completionDueHighRes - now;
|
118
|
+
const newValue = previousValue + (
|
119
|
+
((initialMSToCompletion - msRemaining) / initialMSToCompletion)
|
120
|
+
* (value - previousValue)
|
121
|
+
);
|
122
|
+
|
123
|
+
if (completionDue <= now || Math.abs((value - newValue) / this.progEle.max) < 0.001) {
|
124
|
+
this.progEle.value = value;
|
125
|
+
return;
|
126
|
+
}
|
90
127
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
);
|
95
|
-
this.progEle.value = newValue;
|
96
|
-
};
|
128
|
+
this.progEle.value = newValue;
|
129
|
+
this.animationFrameRequestId = window.requestAnimationFrame(updateProgressStep);
|
130
|
+
}
|
97
131
|
|
98
|
-
this.
|
132
|
+
this.animationFrameRequestId = window.requestAnimationFrame(updateProgressStep);
|
99
133
|
}
|
100
134
|
|
101
135
|
_cancelTimer() {
|
102
|
-
if (this.
|
103
|
-
|
136
|
+
if (this.animationFrameRequestId !== null) {
|
137
|
+
window.cancelAnimationFrame(this.animationFrameRequestId)
|
104
138
|
}
|
105
|
-
this.
|
106
|
-
}
|
107
|
-
|
108
|
-
_clearState() {
|
139
|
+
this.animationFrameRequestId = null;
|
109
140
|
}
|
110
141
|
}
|
111
142
|
|
@@ -136,7 +167,6 @@ class TaskProgressDiv {
|
|
136
167
|
}
|
137
168
|
|
138
169
|
updateFromTask(task) {
|
139
|
-
// console.log(`TaskProgressDiv.updateFromTask`, task);
|
140
170
|
this.div.title = "";
|
141
171
|
|
142
172
|
switch (task.state) {
|
@@ -148,7 +178,14 @@ class TaskProgressDiv {
|
|
148
178
|
case "failed":
|
149
179
|
this._hideProgress();
|
150
180
|
this._showState();
|
151
|
-
|
181
|
+
let title = "Task failed";
|
182
|
+
for (const error of task.errors) {
|
183
|
+
if (error.traceback) {
|
184
|
+
title = `${title}\n${error.traceback}\n\n${error.error_message}`;
|
185
|
+
break;
|
186
|
+
}
|
187
|
+
}
|
188
|
+
this._addTitle(title);
|
152
189
|
break;
|
153
190
|
case "success":
|
154
191
|
this._hideProgress();
|
@@ -201,6 +238,26 @@ class TaskProgressDiv {
|
|
201
238
|
}
|
202
239
|
}
|
203
240
|
|
241
|
+
// -------------------------------------------------------------------------------------------------
|
242
|
+
// Manage an element which just shows one field from the task
|
243
|
+
// -------------------------------------------------------------------------------------------------
|
244
|
+
class TaskFieldElement {
|
245
|
+
constructor (element, fieldName, task) {
|
246
|
+
this.taskId = task.id;
|
247
|
+
this.element = element;
|
248
|
+
this.fieldName = fieldName;
|
249
|
+
this.updateFromTask(task);
|
250
|
+
}
|
251
|
+
|
252
|
+
attachToPoller(poller) {
|
253
|
+
poller.monitorTask(this.taskId, task => this.updateFromTask(task));
|
254
|
+
}
|
255
|
+
|
256
|
+
updateFromTask(task) {
|
257
|
+
this.element.innerHTML = task[this.fieldName];
|
258
|
+
}
|
259
|
+
}
|
260
|
+
|
204
261
|
// -------------------------------------------------------------------------------------------------
|
205
262
|
// Manage a task detail div
|
206
263
|
// -------------------------------------------------------------------------------------------------
|
@@ -295,6 +352,14 @@ class BGTaskPoller {
|
|
295
352
|
|
296
353
|
static normalizeTask(task) {
|
297
354
|
task.updated = new Date(task.updated);
|
355
|
+
|
356
|
+
if (task.completed_at !== null) {
|
357
|
+
task.completed_at = new Date(task.completed_at);
|
358
|
+
const completedTimeAgoMS = Date.now() - task.completed_at.getTime();
|
359
|
+
task.completed_at_time_ago = millisecondsToTimeAgoString(completedTimeAgoMS);
|
360
|
+
} else {
|
361
|
+
task.completed_at_time_ago = "–";
|
362
|
+
}
|
298
363
|
}
|
299
364
|
|
300
365
|
static sharedInstance(baseURL) {
|
@@ -353,7 +418,9 @@ class BGTaskPoller {
|
|
353
418
|
const req = new XMLHttpRequest();
|
354
419
|
const self = this;
|
355
420
|
req.addEventListener("load", function () { self._receivePoll(this); });
|
356
|
-
const
|
421
|
+
const taskIds = Object.keys(this.taskCallbacks).join(",");
|
422
|
+
const url = `${this.baseURL}?tasks=${taskIds}`;
|
423
|
+
console.log(`Poll for tasks ${taskIds}`);
|
357
424
|
req.open("GET", url);
|
358
425
|
req.setRequestHeader('Accept', 'application/json');
|
359
426
|
req.send();
|
@@ -0,0 +1,31 @@
|
|
1
|
+
{% extends "admin/change_list.html" %}
|
2
|
+
{% load static %}
|
3
|
+
|
4
|
+
{% block extrastyle %}
|
5
|
+
{{ block.super }}
|
6
|
+
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}">
|
7
|
+
{% endblock %}
|
8
|
+
|
9
|
+
{% block object-tools %}
|
10
|
+
{{ block.super }}
|
11
|
+
<div style="margin-bottom: 10px;">
|
12
|
+
<h3>Action background tasks</h3>
|
13
|
+
{% if not admin_bg_tasks|length %}
|
14
|
+
<p class="help" style="font-style: italic;">No recent background tasks</p>
|
15
|
+
{% else %}
|
16
|
+
<table>
|
17
|
+
<thead>
|
18
|
+
<tr><th>Task name</th><th>Started</th><th>Finished</th><th>Status</th></tr>
|
19
|
+
</thead>
|
20
|
+
<tbody>
|
21
|
+
{% for bgt in admin_bg_tasks %}
|
22
|
+
{% with bgtask=bgt.task_dict %}
|
23
|
+
<tr class="bgtask-row"><td><a href="{% url 'admin:bgtask_backgroundtask_change' bgtask.id %}">{{ bgt.admin_description }}</a></td><td>{{ bgt.started_at|timesince }} ago</td><td>{% include "bgtask/bg_completed_column.html" %}</td><td>
|
24
|
+
{% include "bgtask/bg_changelist_status_column.html" %}</td></tr>
|
25
|
+
{% endwith %}
|
26
|
+
{% endfor %}
|
27
|
+
</tbody>
|
28
|
+
</table>
|
29
|
+
{% endif %}
|
30
|
+
</div>
|
31
|
+
{% endblock %}
|
@@ -6,7 +6,7 @@
|
|
6
6
|
{{ bgtask|json_script:initialTasksJsonId }}
|
7
7
|
{% endwith %}
|
8
8
|
<script src="{% static 'bgtask/js/bgtask-once.js' %}"></script>
|
9
|
-
<a href="{% url '
|
9
|
+
<a href="{% url 'admin:bgtask_backgroundtask_change' bgtask.id %}">
|
10
10
|
<div id="bgtask-column-{{ bgtask.id }}">
|
11
11
|
{% include 'bgtask/progress.html' %}
|
12
12
|
</div>
|
@@ -0,0 +1,24 @@
|
|
1
|
+
{% load static %}
|
2
|
+
{% if not bgtask %}
|
3
|
+
–
|
4
|
+
{% else %}
|
5
|
+
{% with initialTasksJsonId="initialTasksJson-completedcolumn-"|add:bgtask.id %}
|
6
|
+
{{ bgtask|json_script:initialTasksJsonId }}
|
7
|
+
{% endwith %}
|
8
|
+
<script src="{% static 'bgtask/js/bgtask-once.js' %}"></script>
|
9
|
+
<span></span>
|
10
|
+
<script>
|
11
|
+
(() => {
|
12
|
+
const completedSpan = document.currentScript.previousElementSibling;
|
13
|
+
|
14
|
+
const originalTask = JSON.parse(
|
15
|
+
document.getElementById("initialTasksJson-completedcolumn-{{ bgtask.id }}").textContent
|
16
|
+
);
|
17
|
+
BGTaskPoller.normalizeTask(originalTask);
|
18
|
+
|
19
|
+
const statusCol = new TaskFieldElement(completedSpan, "completed_at_time_ago", originalTask);
|
20
|
+
const poller = BGTaskPoller.sharedInstance("{% url 'bgtask:tasks' %}");
|
21
|
+
statusCol.attachToPoller(poller);
|
22
|
+
})();
|
23
|
+
</script>
|
24
|
+
{% endif %}
|
bgtask/views.py
CHANGED
@@ -10,15 +10,8 @@ from .models import BackgroundTask
|
|
10
10
|
Q_NONE = Q(pk__in=[])
|
11
11
|
|
12
12
|
|
13
|
-
def task_dict(task):
|
14
|
-
task_dict = model_to_dict(task)
|
15
|
-
if "stack_context" in task_dict:
|
16
|
-
del task_dict["stack_context"]
|
17
|
-
return {"id": str(task.id), "updated": task.updated.isoformat(), **task_dict}
|
18
|
-
|
19
|
-
|
20
13
|
def _tasks_dict(tasks):
|
21
|
-
td = {str(task.id): task_dict
|
14
|
+
td = {str(task.id): task.task_dict for task in tasks}
|
22
15
|
return td
|
23
16
|
|
24
17
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: django-admin-background-task
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.0
|
4
4
|
Summary: A set of tools for django apps to persist and monitor the status of background tasks
|
5
5
|
Home-page: https://github.com/daphtdazz/django-bgtask
|
6
6
|
License: Apache-2.0
|
@@ -41,7 +41,7 @@ INSTALLED_APPS = [
|
|
41
41
|
]
|
42
42
|
```
|
43
43
|
|
44
|
-
And mount the
|
44
|
+
And mount the admin monitoring URLs:
|
45
45
|
|
46
46
|
```
|
47
47
|
urlpatterns = [
|
@@ -0,0 +1,29 @@
|
|
1
|
+
bgtask/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
bgtask/admin.py,sha256=cOhbR__7WJ3rmjlbZjGKh_gJDpEz_c6Ll2zwHo7mpiM,965
|
3
|
+
bgtask/apps.py,sha256=zZHsNaW9nnJgK7jnpty0IjM0TTpsk33xhpsGTa-x3iE,144
|
4
|
+
bgtask/backends/__init__.py,sha256=zZlL7gHXL12heAhHD_7D7lEBihCE-LHYb07yIbk055s,57
|
5
|
+
bgtask/backends/thread_pool.py,sha256=y5EtO9ltA5lc7GUtvqHPJuoo1brJPOurp1IQ7JdCUEI,304
|
6
|
+
bgtask/decorators.py,sha256=UsiCBhqaWm1Yn4n4hmFbauGQvNezoeDm7Iqpjbvxbjk,1119
|
7
|
+
bgtask/migrations/0001_initial.py,sha256=YDTEy_hiXHru-1ZdIH-n3po82r5vyzbkyYIrF9_yiI8,2013
|
8
|
+
bgtask/migrations/0002_backgroundtask_namespace_alter_backgroundtask_name.py,sha256=XIG_gIvdA73tz0NRbNkGU_ieKde3ZtQltPeLdslD5Xo,968
|
9
|
+
bgtask/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
+
bgtask/model_admin.py,sha256=mwRoljw1CwMA-c3zbssmrRsV9l8xsgYJOBrkgVNilRs,4402
|
11
|
+
bgtask/models.py,sha256=VulETkcBXJkyUgmExSx9fuOvpp1HnY2sLQLD5BUvbHk,9184
|
12
|
+
bgtask/stack_contexts.py,sha256=2Hun6O5Mm949uAioC2E3i54Rg-_H8P7LsHdhJYc0w6E,231
|
13
|
+
bgtask/static/bgtask/js/bgtask-once.js,sha256=DBerpxPLP21IH--HpBxVFk3kN52FtBiuyJsDCjg7mCA,746
|
14
|
+
bgtask/static/bgtask/js/bgtask.js,sha256=aYG02BY2F0aBlyRT_19turcC6RNLmyhJZUiuABUszoo,15026
|
15
|
+
bgtask/templates/bgtask/admin/change_list.html,sha256=nR7LQ9yPv1LgD97g9oU4d8P6wxq9HrEJ4nneA14msNM,1053
|
16
|
+
bgtask/templates/bgtask/bg_changelist_status_column.html,sha256=SA7EAL50oBrcRPGstXROXkFJ541vYA8RlHidM3srTPU,844
|
17
|
+
bgtask/templates/bgtask/bg_completed_column.html,sha256=Q4l3EQaOXHYVOzv0jpx3ww3Wa2Bb33RyZp3RSWI1zog,788
|
18
|
+
bgtask/templates/bgtask/bgtask_templates.html,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
19
|
+
bgtask/templates/bgtask/bgtask_view.html,sha256=AeubqERFF14PHWjInBD39wGImnRx4taS42iOMOPDUlM,1845
|
20
|
+
bgtask/templates/bgtask/progress.html,sha256=VVYbrunGasOshRlz8Nm8hFPxqcVtrpaW1e2QxzAMcMM,166
|
21
|
+
bgtask/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
22
|
+
bgtask/templatetags/bgtask.py,sha256=WUEWlQX5AarbXJBV6EGp-khT1zegVwGNigSKRF5mCTE,413
|
23
|
+
bgtask/tests/test_bgtask.py,sha256=9AsBppPphaIdphuB-pHuQjgLhYNp8ySWtmcWF2uQl3Q,1090
|
24
|
+
bgtask/urls.py,sha256=DXXjgj18LTSCq2-xZf23-pkgO4RmZglv67mgrp-fDyk,161
|
25
|
+
bgtask/views.py,sha256=tPblidBq8mIv9cbyAfzJfNlM-I-Z11XVoobdA3DZBVM,1765
|
26
|
+
django_admin_background_task-0.3.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
27
|
+
django_admin_background_task-0.3.0.dist-info/METADATA,sha256=mDJm-1PabE_GFKUSxv9chBhiBhmtbBAaiyvlKxr0MQ4,1416
|
28
|
+
django_admin_background_task-0.3.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
29
|
+
django_admin_background_task-0.3.0.dist-info/RECORD,,
|
bgtask/.DS_Store
DELETED
Binary file
|
@@ -1,23 +0,0 @@
|
|
1
|
-
bgtask/.DS_Store,sha256=TdvU4O6xL31hGFKIbymkCVOa_vP5cJS4x-K9dIzwxGI,6148
|
2
|
-
bgtask/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
bgtask/admin.py,sha256=G400_bFRTFzYexKajrJ3eS6lvy4mFBWQq7hGXEZxEJk,854
|
4
|
-
bgtask/apps.py,sha256=zZHsNaW9nnJgK7jnpty0IjM0TTpsk33xhpsGTa-x3iE,144
|
5
|
-
bgtask/migrations/0001_initial.py,sha256=YDTEy_hiXHru-1ZdIH-n3po82r5vyzbkyYIrF9_yiI8,2013
|
6
|
-
bgtask/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
-
bgtask/models.py,sha256=04Cz_XgibkRukbteZcaI2qFuYEzwHfe04Ucxt1cLCpE,7683
|
8
|
-
bgtask/stack_contexts.py,sha256=2Hun6O5Mm949uAioC2E3i54Rg-_H8P7LsHdhJYc0w6E,231
|
9
|
-
bgtask/static/bgtask/js/bgtask-once.js,sha256=DBerpxPLP21IH--HpBxVFk3kN52FtBiuyJsDCjg7mCA,746
|
10
|
-
bgtask/static/bgtask/js/bgtask.js,sha256=0S_emqYF1WStRWgOAqPn0gKst4Ytn9EGnWJelkecesU,12636
|
11
|
-
bgtask/templates/bgtask/bg_changelist_status_column.html,sha256=AKvJvIoPG7LVmRvcsNGzOExe0qT71Yt4OqOpXqxsMKc,852
|
12
|
-
bgtask/templates/bgtask/bgtask_templates.html,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
13
|
-
bgtask/templates/bgtask/bgtask_view.html,sha256=AeubqERFF14PHWjInBD39wGImnRx4taS42iOMOPDUlM,1845
|
14
|
-
bgtask/templates/bgtask/progress.html,sha256=VVYbrunGasOshRlz8Nm8hFPxqcVtrpaW1e2QxzAMcMM,166
|
15
|
-
bgtask/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
16
|
-
bgtask/templatetags/bgtask.py,sha256=WUEWlQX5AarbXJBV6EGp-khT1zegVwGNigSKRF5mCTE,413
|
17
|
-
bgtask/tests/test_bgtask.py,sha256=9AsBppPphaIdphuB-pHuQjgLhYNp8ySWtmcWF2uQl3Q,1090
|
18
|
-
bgtask/urls.py,sha256=DXXjgj18LTSCq2-xZf23-pkgO4RmZglv67mgrp-fDyk,161
|
19
|
-
bgtask/views.py,sha256=4ypwMNwH_N9iKOrP5Ub_rLGFVV5BUMvuZVkCBXoSa7k,1983
|
20
|
-
django_admin_background_task-0.1.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
21
|
-
django_admin_background_task-0.1.0.dist-info/METADATA,sha256=IIZyk-zPNWp-1f_xiA7Q-7LjZ-_0fQ-LObBMuwCqs90,1416
|
22
|
-
django_admin_background_task-0.1.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
23
|
-
django_admin_background_task-0.1.0.dist-info/RECORD,,
|
File without changes
|
{django_admin_background_task-0.1.0.dist-info → django_admin_background_task-0.3.0.dist-info}/WHEEL
RENAMED
File without changes
|