django-admin-background-task 0.4.0__tar.gz → 0.4.3__tar.gz
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_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/PKG-INFO +2 -1
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/README.md +1 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/model_admin.py +2 -1
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/models.py +81 -58
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templates/bgtask/admin/change_list.html +1 -1
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/tests/test_bgtask.py +37 -2
- django_admin_background_task-0.4.3/bgtask/utils.py +56 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/views.py +7 -12
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/pyproject.toml +2 -1
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/LICENSE +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/__init__.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/admin.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/apps.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/backends/__init__.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/backends/thread_pool.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/decorators.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/migrations/0001_initial.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/migrations/0002_backgroundtask_namespace_alter_backgroundtask_name.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/migrations/0003_backgroundtask_queued_at.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/migrations/__init__.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/static/bgtask/js/bgtask-once.js +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/static/bgtask/js/bgtask.js +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templates/bgtask/bg_changelist_status_column.html +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templates/bgtask/bg_completed_column.html +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templates/bgtask/bgtask_templates.html +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templates/bgtask/bgtask_view.html +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templates/bgtask/progress.html +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templatetags/__init__.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/templatetags/bgtask.py +0 -0
- {django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/urls.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: django-admin-background-task
|
3
|
-
Version: 0.4.
|
3
|
+
Version: 0.4.3
|
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
|
@@ -58,4 +58,5 @@ urlpatterns = [
|
|
58
58
|
from bgtask.models import BackgroundTask
|
59
59
|
|
60
60
|
BackgroundTask.new(name)
|
61
|
+
```
|
61
62
|
|
{django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/model_admin.py
RENAMED
@@ -103,7 +103,7 @@ class BGTaskModelAdmin(admin.ModelAdmin):
|
|
103
103
|
if not task_name_to_desc:
|
104
104
|
return BackgroundTask.objects.none()
|
105
105
|
|
106
|
-
bgts =
|
106
|
+
bgts = (
|
107
107
|
BackgroundTask.objects.filter(
|
108
108
|
name__in=task_name_to_desc, namespace=self._bgtask_namespace
|
109
109
|
)
|
@@ -123,6 +123,7 @@ class BGTaskModelAdmin(admin.ModelAdmin):
|
|
123
123
|
)
|
124
124
|
.order_by("-started_at", "-queued_at")
|
125
125
|
)
|
126
|
+
bgts.add_position_in_queue()
|
126
127
|
for bgt in bgts:
|
127
128
|
bgt.admin_description = task_name_to_desc[bgt.name]
|
128
129
|
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import
|
1
|
+
import collections
|
2
2
|
import logging
|
3
3
|
import os
|
4
4
|
import time
|
@@ -8,61 +8,52 @@ from contextlib import contextmanager
|
|
8
8
|
|
9
9
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
10
10
|
from django.contrib.contenttypes.models import ContentType
|
11
|
-
from django.db import models
|
11
|
+
from django.db import models
|
12
12
|
from django.forms.models import model_to_dict
|
13
13
|
from django.utils import timezone
|
14
14
|
|
15
15
|
from model_utils import Choices
|
16
16
|
|
17
|
-
|
18
|
-
log = logging.getLogger(__name__)
|
17
|
+
from .utils import locked, only_if_state, q_or
|
19
18
|
|
20
19
|
|
21
|
-
|
22
|
-
@functools.wraps(meth)
|
23
|
-
def _locked_meth(self, *args, **kwargs):
|
24
|
-
if getattr(self, "_locked", False):
|
25
|
-
return meth(self, *args, **kwargs)
|
20
|
+
log = logging.getLogger(__name__)
|
26
21
|
|
27
|
-
with transaction.atomic():
|
28
|
-
BackgroundTask.objects.filter(id=self.id).select_for_update().only("id").get()
|
29
|
-
self.refresh_from_db()
|
30
22
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
23
|
+
class BackgroundTaskQuerySet(models.QuerySet):
|
24
|
+
def add_position_in_queue(self):
|
25
|
+
"""This evaluates the queryset and adds position_in_queue to each one (which requires
|
26
|
+
more DB queries).
|
27
|
+
"""
|
28
|
+
recent_unqueued_task_by_nsn = {(task.namespace, task.name): None for task in self}
|
29
|
+
for ns, name in recent_unqueued_task_by_nsn:
|
30
|
+
recent_unqueued_task_by_nsn[(ns, name)] = BackgroundTask.most_recently_unqueued_task(
|
31
|
+
ns, name
|
32
|
+
)
|
37
33
|
|
38
|
-
|
34
|
+
queued_tasks_by_nsn = BackgroundTask.queued_tasks_in_order_by_nsn_like(self)
|
39
35
|
|
36
|
+
for task in self:
|
37
|
+
if task.state != task.STATES.queued:
|
38
|
+
continue
|
40
39
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
raise RuntimeError(
|
47
|
-
"%s cannot execute %s as in state %s not one of %s"
|
48
|
-
% (self, meth.__name__, self.state, states)
|
49
|
-
)
|
50
|
-
return meth(self, *args, **kwargs)
|
40
|
+
unqueued_task = recent_unqueued_task_by_nsn[(task.namespace, task.name)]
|
41
|
+
if unqueued_task is not None and task.queued_at < unqueued_task.queued_at:
|
42
|
+
# More recently queued task has already been dispatched so we should be going...
|
43
|
+
task.position_in_queue = 0
|
44
|
+
continue
|
51
45
|
|
52
|
-
|
46
|
+
task.position_in_queue = 0
|
47
|
+
for queued_task in queued_tasks_by_nsn[(task.namespace, task.name)]:
|
48
|
+
if queued_task.queued_at >= task.queued_at:
|
49
|
+
break
|
50
|
+
task.position_in_queue += 1
|
53
51
|
|
54
|
-
|
52
|
+
return self
|
55
53
|
|
56
54
|
|
57
55
|
class BackgroundTask(models.Model):
|
58
56
|
id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4)
|
59
|
-
name = models.CharField(
|
60
|
-
max_length=1000,
|
61
|
-
help_text=(
|
62
|
-
"Name (or type) of this task, is not unique "
|
63
|
-
"per task instance but generally per task functionality"
|
64
|
-
),
|
65
|
-
)
|
66
57
|
namespace = models.CharField(
|
67
58
|
max_length=1000,
|
68
59
|
default="",
|
@@ -72,6 +63,13 @@ class BackgroundTask(models.Model):
|
|
72
63
|
"entire codebase, allowing them to be shorter and human readable"
|
73
64
|
),
|
74
65
|
)
|
66
|
+
name = models.CharField(
|
67
|
+
max_length=1000,
|
68
|
+
help_text=(
|
69
|
+
"Name (or type) of this task, is not unique "
|
70
|
+
"per task instance but generally per task functionality"
|
71
|
+
),
|
72
|
+
)
|
75
73
|
|
76
74
|
STATES = Choices("not_started", "queued", "running", "success", "partial_success", "failed")
|
77
75
|
state = models.CharField(max_length=16, default=STATES.not_started, choices=STATES)
|
@@ -100,16 +98,22 @@ class BackgroundTask(models.Model):
|
|
100
98
|
created = models.DateTimeField(auto_now_add=True)
|
101
99
|
updated = models.DateTimeField(auto_now=True)
|
102
100
|
|
101
|
+
objects = models.Manager.from_queryset(BackgroundTaskQuerySet)()
|
102
|
+
|
103
103
|
class Meta:
|
104
104
|
ordering = ["created", "id"]
|
105
105
|
|
106
|
+
# This needs to be added dynamically to model instances, and is done by
|
107
|
+
# BackgroundTaskQuerySet.add_position_in_queue()
|
108
|
+
position_in_queue = None
|
109
|
+
|
106
110
|
@property
|
107
111
|
def task_dict(self):
|
108
112
|
task_dict = model_to_dict(self)
|
109
113
|
return {
|
110
114
|
"id": str(self.id),
|
111
115
|
"updated": self.updated.isoformat(),
|
112
|
-
"position_in_queue": self.
|
116
|
+
"position_in_queue": self.position_in_queue,
|
113
117
|
**task_dict,
|
114
118
|
}
|
115
119
|
|
@@ -121,27 +125,32 @@ class BackgroundTask(models.Model):
|
|
121
125
|
def incomplete(self):
|
122
126
|
return self.state in [self.STATES.not_started, self.STATES.running]
|
123
127
|
|
124
|
-
|
125
|
-
|
126
|
-
|
128
|
+
@classmethod
|
129
|
+
def most_recently_unqueued_task(cls, namespace, name):
|
130
|
+
return (
|
131
|
+
cls.objects.filter(queued_at__isnull=False, namespace=namespace, name=name)
|
132
|
+
.exclude(state=cls.STATES.not_started)
|
133
|
+
.exclude(state=cls.STATES.queued)
|
134
|
+
.order_by("-queued_at")
|
135
|
+
.first()
|
136
|
+
)
|
127
137
|
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
.
|
134
|
-
.
|
138
|
+
@classmethod
|
139
|
+
def queued_tasks_in_order_by_nsn_like(cls, tasks):
|
140
|
+
queued_by_nsn = collections.defaultdict(list)
|
141
|
+
nsns = {(task.namespace, task.name) for task in tasks}
|
142
|
+
for task in (
|
143
|
+
cls.objects.filter(queued_at__isnull=False, state=cls.STATES.queued)
|
144
|
+
.filter(q_or(models.Q(namespace=nsn[0], name=nsn[1]) for nsn in nsns))
|
145
|
+
.order_by("queued_at")
|
135
146
|
):
|
136
|
-
|
137
|
-
|
147
|
+
queued_by_nsn[(task.namespace, task.name)].append(task)
|
148
|
+
return queued_by_nsn
|
138
149
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
queued_at__lt=self.queued_at, state=self.STATES.queued
|
144
|
-
).count()
|
150
|
+
def set_steps_to_complete(self, steps_to_complete):
|
151
|
+
self.steps_to_complete = steps_to_complete
|
152
|
+
self.steps_completed = 0
|
153
|
+
self.save()
|
145
154
|
|
146
155
|
@contextmanager
|
147
156
|
def runs_single_step(self):
|
@@ -161,6 +170,14 @@ class BackgroundTask(models.Model):
|
|
161
170
|
else:
|
162
171
|
self.succeed()
|
163
172
|
|
173
|
+
@contextmanager
|
174
|
+
def fails_if_exception(self):
|
175
|
+
try:
|
176
|
+
yield
|
177
|
+
except Exception as exc:
|
178
|
+
self.fail(exc)
|
179
|
+
raise
|
180
|
+
|
164
181
|
@locked
|
165
182
|
@only_if_state(STATES.not_started)
|
166
183
|
def queue(self):
|
@@ -170,7 +187,13 @@ class BackgroundTask(models.Model):
|
|
170
187
|
self.save()
|
171
188
|
|
172
189
|
@locked
|
173
|
-
@only_if_state(
|
190
|
+
@only_if_state(
|
191
|
+
(STATES.not_started, STATES.queued),
|
192
|
+
# Allow start() to be called while running so that if a task's subtasks are queued and
|
193
|
+
# asynchronous each can call .start() independently so the first one that is executed will
|
194
|
+
# start the task.
|
195
|
+
no_op_states=(STATES.running,),
|
196
|
+
)
|
174
197
|
def start(self):
|
175
198
|
log.info("Background Task starting: %s", self.id)
|
176
199
|
self.state = self.STATES.running
|
@@ -12,7 +12,7 @@
|
|
12
12
|
{% if not admin_bg_tasks|length %}
|
13
13
|
<p class="help" style="font-style: italic;">No recent background tasks</p>
|
14
14
|
{% else %}
|
15
|
-
<table>
|
15
|
+
<table style="max-height: 300px; display: block; overflow-y: scroll;">
|
16
16
|
<thead>
|
17
17
|
<tr><th>Task name</th><th>Started</th><th>Finished</th><th>Status</th></tr>
|
18
18
|
</thead>
|
@@ -1,7 +1,11 @@
|
|
1
1
|
import pytest
|
2
2
|
|
3
|
+
from django.utils import timezone
|
4
|
+
|
3
5
|
from bgtask.models import BackgroundTask
|
4
6
|
|
7
|
+
pytestmark = pytest.mark.django_db
|
8
|
+
|
5
9
|
|
6
10
|
@pytest.fixture
|
7
11
|
def a_task():
|
@@ -30,6 +34,37 @@ def test_bgtask_immediate_failure(a_task):
|
|
30
34
|
assert a_task.state == BackgroundTask.STATES.failed
|
31
35
|
|
32
36
|
assert len(a_task.errors) == 1
|
33
|
-
assert a_task.errors[0]["datetime"] ==
|
37
|
+
assert a_task.errors[0]["datetime"] == a_task.completed_at.isoformat()
|
34
38
|
assert a_task.errors[0]["error_message"] == "Some global failure"
|
35
|
-
assert "test_bgtask_immediate_failure" in a_task.errors[0]["
|
39
|
+
assert "test_bgtask_immediate_failure" in a_task.errors[0]["traceback"]
|
40
|
+
|
41
|
+
|
42
|
+
def test_bgtask_start_again(a_task):
|
43
|
+
a_task.start()
|
44
|
+
import time
|
45
|
+
time.sleep(0.001)
|
46
|
+
|
47
|
+
curr_started_at = a_task.started_at
|
48
|
+
assert timezone.now() > curr_started_at
|
49
|
+
|
50
|
+
# Starting again is no-op
|
51
|
+
a_task.start()
|
52
|
+
assert a_task.started_at == curr_started_at
|
53
|
+
|
54
|
+
|
55
|
+
def test_fails_if_exception(a_task):
|
56
|
+
a_task.start()
|
57
|
+
|
58
|
+
with pytest.raises(Exception):
|
59
|
+
with a_task.fails_if_exception():
|
60
|
+
raise Exception("fail!")
|
61
|
+
|
62
|
+
assert a_task.state == BackgroundTask.STATES.failed
|
63
|
+
|
64
|
+
|
65
|
+
def test_set_steps(a_task):
|
66
|
+
a_task.steps_completed = 10
|
67
|
+
a_task.set_steps_to_complete(20)
|
68
|
+
a_task.refresh_from_db()
|
69
|
+
assert a_task.steps_to_complete == 20
|
70
|
+
assert a_task.steps_completed == 0
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import functools
|
2
|
+
import operator
|
3
|
+
from typing import Iterable
|
4
|
+
|
5
|
+
from django.db import models, transaction
|
6
|
+
|
7
|
+
|
8
|
+
# https://stackoverflow.com/questions/29900386/how-to-construct-django-q-object-matching-none
|
9
|
+
Q_NONE = models.Q(pk__in=[])
|
10
|
+
|
11
|
+
|
12
|
+
def q_or(q_objects: Iterable[models.Q]):
|
13
|
+
"""Chain an iterable of Q with OR.
|
14
|
+
|
15
|
+
The more Q()s are passed, the more things are filtered in, so it
|
16
|
+
makes sense if nothing is passed nothing is filtered in.
|
17
|
+
"""
|
18
|
+
return functools.reduce(operator.or_, q_objects, models.Q()) or Q_NONE
|
19
|
+
|
20
|
+
|
21
|
+
def locked(meth):
|
22
|
+
@functools.wraps(meth)
|
23
|
+
def _locked_meth(self, *args, **kwargs):
|
24
|
+
if getattr(self, "_locked", False):
|
25
|
+
return meth(self, *args, **kwargs)
|
26
|
+
|
27
|
+
with transaction.atomic():
|
28
|
+
type(self).objects.filter(id=self.id).select_for_update().only("id").get()
|
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
|
37
|
+
|
38
|
+
return _locked_meth
|
39
|
+
|
40
|
+
|
41
|
+
def only_if_state(state, no_op_states=frozenset()):
|
42
|
+
def only_if_state_decorator(meth):
|
43
|
+
def only_if_state_wrapper(self, *args, no_op_if_already_in_state=False, **kwargs):
|
44
|
+
if self.state in no_op_states:
|
45
|
+
return
|
46
|
+
states = (state,) if isinstance(state, str) else tuple(state)
|
47
|
+
if self.state not in states:
|
48
|
+
raise RuntimeError(
|
49
|
+
"%s cannot execute %s as in state %s not one of %s"
|
50
|
+
% (self, meth.__name__, self.state, states)
|
51
|
+
)
|
52
|
+
return meth(self, *args, **kwargs)
|
53
|
+
|
54
|
+
return only_if_state_wrapper
|
55
|
+
|
56
|
+
return only_if_state_decorator
|
@@ -1,6 +1,5 @@
|
|
1
1
|
from django.core.exceptions import ValidationError
|
2
2
|
from django.db.models import Q
|
3
|
-
from django.forms.models import model_to_dict
|
4
3
|
from django.http import Http404, HttpResponseBadRequest, JsonResponse
|
5
4
|
from django.shortcuts import render
|
6
5
|
|
@@ -31,18 +30,12 @@ def background_tasks_view(request):
|
|
31
30
|
tasks = request.GET.get("tasks", "")
|
32
31
|
object_id = request.GET.get("object_id", None)
|
33
32
|
if tasks is None and object_id is None:
|
34
|
-
return HttpResponseBadRequest(
|
35
|
-
|
36
|
-
|
33
|
+
return HttpResponseBadRequest("Must pass 'tasks' or 'object_id' as a query parameter")
|
34
|
+
task_ids = tasks.split(",")
|
35
|
+
task_ids_q = Q(id__in=task_ids) if tasks else Q_NONE
|
36
|
+
object_id_q = Q(acted_on_object_id=object_id) if object_id is not None else Q_NONE
|
37
|
+
tasks = BackgroundTask.objects.filter(task_ids_q | object_id_q).order_by("-created")
|
37
38
|
try:
|
38
|
-
task_ids = tasks.split(",")
|
39
|
-
task_ids_q = Q(id__in=task_ids) if tasks else Q_NONE
|
40
|
-
object_id_q = (
|
41
|
-
Q(acted_on_object_id=object_id) if object_id is not None else Q_NONE
|
42
|
-
)
|
43
|
-
tasks = BackgroundTask.objects.filter(task_ids_q | object_id_q).order_by(
|
44
|
-
"-created"
|
45
|
-
)
|
46
39
|
if len(tasks) == 0:
|
47
40
|
raise ValidationError("Unfound tasks")
|
48
41
|
except ValidationError:
|
@@ -52,6 +45,8 @@ def background_tasks_view(request):
|
|
52
45
|
|
53
46
|
accepts = request.headers.get("Accept", "").split(",")
|
54
47
|
|
48
|
+
tasks.add_position_in_queue()
|
49
|
+
|
55
50
|
if "application/json" in accepts:
|
56
51
|
return background_tasks_view_json(tasks)
|
57
52
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
4
4
|
|
5
5
|
[tool.poetry]
|
6
6
|
name = "django-admin-background-task"
|
7
|
-
version = "0.4.
|
7
|
+
version = "0.4.3"
|
8
8
|
description = "A set of tools for django apps to persist and monitor the status of background tasks"
|
9
9
|
authors = [
|
10
10
|
"David Park <david@greenparksoftware.co.uk>",
|
@@ -38,6 +38,7 @@ sphinx = ">=7.1.2"
|
|
38
38
|
sphinx-rtd-theme = ">=1.3.0"
|
39
39
|
psycopg2 = ">=2.9.10"
|
40
40
|
colorlog = ">=6.9.0"
|
41
|
+
flake8 = ">=7.1.1"
|
41
42
|
|
42
43
|
[tool.poetry.extras]
|
43
44
|
# ℹ️ if you update any of these, be sure to update "all = [...]" as well, so that it's easy to get
|
File without changes
|
{django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/__init__.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{django_admin_background_task-0.4.0 → django_admin_background_task-0.4.3}/bgtask/decorators.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|