django-admin-background-task 0.4.0__py3-none-any.whl → 0.4.3__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/model_admin.py CHANGED
@@ -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 = list(
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
 
bgtask/models.py CHANGED
@@ -1,4 +1,4 @@
1
- import functools
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, transaction
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
- 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)
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
- # 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
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
- return _locked_meth
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
- def only_if_state(state):
42
- def only_if_state_decorator(meth):
43
- def only_if_state_wrapper(self, *args, **kwargs):
44
- states = (state,) if isinstance(state, str) else tuple(state)
45
- if self.state not in states:
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
- return only_if_state_wrapper
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
- return only_if_state_decorator
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.get_position_in_queue(),
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
- def get_position_in_queue(self):
125
- if self.state != self.STATES.queued:
126
- return None
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
- if (
129
- BackgroundTask.objects.filter(
130
- queued_at__gt=self.queued_at,
131
- )
132
- .exclude(state=self.STATES.not_started)
133
- .exclude(state=self.STATES.queued)
134
- .exists()
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
- # More recent tasks have started, assume we're next (either that or we're stuck)
137
- return 0
147
+ queued_by_nsn[(task.namespace, task.name)].append(task)
148
+ return queued_by_nsn
138
149
 
139
- # Otherwise approximate queue position as the number of tasks that were queued before us
140
- # and are still in the queue. So if there is nothing in front of us we are 0 in the queue.
141
- assert self.queued_at
142
- return BackgroundTask.objects.filter(
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((STATES.not_started, STATES.queued))
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"] == "1970-01-01T00:00:00+00:00"
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]["trackeback"]
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
bgtask/utils.py ADDED
@@ -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
bgtask/views.py CHANGED
@@ -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
- "Must pass 'tasks' or 'object_id' as a query parameter"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-admin-background-task
3
- Version: 0.4.0
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
 
@@ -8,11 +8,11 @@ bgtask/migrations/0001_initial.py,sha256=TPuplGYJtqHJ7h4-A-nINMmvRD9HYvfhOY4bZyi
8
8
  bgtask/migrations/0002_backgroundtask_namespace_alter_backgroundtask_name.py,sha256=XIG_gIvdA73tz0NRbNkGU_ieKde3ZtQltPeLdslD5Xo,968
9
9
  bgtask/migrations/0003_backgroundtask_queued_at.py,sha256=Hp0WeQLKP0R5jNeEAgVlsx-7jgIzbnIkqkPUGsenK8o,439
10
10
  bgtask/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- bgtask/model_admin.py,sha256=loEh9uPUZbC19_daPoPfHNuLrI42oAA930J5IEH_1-w,5057
12
- bgtask/models.py,sha256=meuAEcYP--YocGmQ8kVcvK7zCoom9vGSQX0EWiuSn3U,10566
11
+ bgtask/model_admin.py,sha256=Of0Le1IV3482HurWyi-FB2TquuRXtDxDK0tEtc-OFtE,5090
12
+ bgtask/models.py,sha256=FOpUaLu72OXRTDyNSe1gKOp5okWMFboDKTlXkR1Cyn0,11616
13
13
  bgtask/static/bgtask/js/bgtask-once.js,sha256=DBerpxPLP21IH--HpBxVFk3kN52FtBiuyJsDCjg7mCA,746
14
14
  bgtask/static/bgtask/js/bgtask.js,sha256=6fVepM6AY_a3-I5ZABfaCaf7_g8IkClOFq9ZxG9GsX4,15366
15
- bgtask/templates/bgtask/admin/change_list.html,sha256=pxajrUXZvsBfSM4Lh3HLjdjGNNO7lJPaTkUwZ_zbWEc,1080
15
+ bgtask/templates/bgtask/admin/change_list.html,sha256=cb5v5jkTsHgU7cZRQchxra0aXUXAvlBlxPZ0ZjLQPNc,1143
16
16
  bgtask/templates/bgtask/bg_changelist_status_column.html,sha256=SA7EAL50oBrcRPGstXROXkFJ541vYA8RlHidM3srTPU,844
17
17
  bgtask/templates/bgtask/bg_completed_column.html,sha256=Q4l3EQaOXHYVOzv0jpx3ww3Wa2Bb33RyZp3RSWI1zog,788
18
18
  bgtask/templates/bgtask/bgtask_templates.html,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
@@ -20,10 +20,11 @@ bgtask/templates/bgtask/bgtask_view.html,sha256=AeubqERFF14PHWjInBD39wGImnRx4taS
20
20
  bgtask/templates/bgtask/progress.html,sha256=VVYbrunGasOshRlz8Nm8hFPxqcVtrpaW1e2QxzAMcMM,166
21
21
  bgtask/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  bgtask/templatetags/bgtask.py,sha256=WUEWlQX5AarbXJBV6EGp-khT1zegVwGNigSKRF5mCTE,413
23
- bgtask/tests/test_bgtask.py,sha256=9AsBppPphaIdphuB-pHuQjgLhYNp8ySWtmcWF2uQl3Q,1090
23
+ bgtask/tests/test_bgtask.py,sha256=pepgFBvRWNqmOdOFPkp4MC_ctWmYPYGpisb94OUUA9I,1882
24
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.4.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
27
- django_admin_background_task-0.4.0.dist-info/METADATA,sha256=8IjDh7pOq9dKgYCqPr_fbHgI4rZNmRDeUB2NApdmCT4,1375
28
- django_admin_background_task-0.4.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
29
- django_admin_background_task-0.4.0.dist-info/RECORD,,
25
+ bgtask/utils.py,sha256=lc3V276dEper-3d5xgwTdsfmsKOLv9KShrPkFCkPzlg,1789
26
+ bgtask/views.py,sha256=O-8J8VBLXnbBkbDseu-5y0hGt3aU5r-awsTq8Rdg7mc,1670
27
+ django_admin_background_task-0.4.3.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
28
+ django_admin_background_task-0.4.3.dist-info/METADATA,sha256=T709ili5frQROuOnoU16OnODFaS9LEpJskRlCN0doQo,1379
29
+ django_admin_background_task-0.4.3.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
30
+ django_admin_background_task-0.4.3.dist-info/RECORD,,