django-admin-background-task 0.3.2__tar.gz → 0.4.1__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.
Files changed (30) hide show
  1. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/PKG-INFO +2 -1
  2. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/README.md +1 -0
  3. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/backends/thread_pool.py +2 -2
  4. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/migrations/0001_initial.py +1 -1
  5. django_admin_background_task-0.4.1/bgtask/migrations/0003_backgroundtask_queued_at.py +18 -0
  6. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/model_admin.py +23 -7
  7. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/models.py +67 -9
  8. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/static/bgtask/js/bgtask.js +18 -3
  9. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templates/bgtask/admin/change_list.html +5 -2
  10. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/tests/test_bgtask.py +37 -2
  11. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/pyproject.toml +3 -1
  12. django_admin_background_task-0.3.2/bgtask/stack_contexts.py +0 -11
  13. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/LICENSE +0 -0
  14. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/__init__.py +0 -0
  15. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/admin.py +0 -0
  16. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/apps.py +0 -0
  17. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/backends/__init__.py +0 -0
  18. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/decorators.py +0 -0
  19. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/migrations/0002_backgroundtask_namespace_alter_backgroundtask_name.py +0 -0
  20. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/migrations/__init__.py +0 -0
  21. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/static/bgtask/js/bgtask-once.js +0 -0
  22. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templates/bgtask/bg_changelist_status_column.html +0 -0
  23. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templates/bgtask/bg_completed_column.html +0 -0
  24. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templates/bgtask/bgtask_templates.html +0 -0
  25. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templates/bgtask/bgtask_view.html +0 -0
  26. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templates/bgtask/progress.html +0 -0
  27. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templatetags/__init__.py +0 -0
  28. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/templatetags/bgtask.py +0 -0
  29. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/urls.py +0 -0
  30. {django_admin_background_task-0.3.2 → django_admin_background_task-0.4.1}/bgtask/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-admin-background-task
3
- Version: 0.3.2
3
+ Version: 0.4.1
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
 
@@ -39,3 +39,4 @@ urlpatterns = [
39
39
  from bgtask.models import BackgroundTask
40
40
 
41
41
  BackgroundTask.new(name)
42
+ ```
@@ -4,9 +4,9 @@ from concurrent.futures import ThreadPoolExecutor
4
4
  SHARED_THREAD_POOL = None
5
5
 
6
6
 
7
- def dispatch(func, bg_task, *args, **kwargs):
7
+ def dispatch(func, *args, **kwargs):
8
8
  global SHARED_THREAD_POOL
9
9
  if SHARED_THREAD_POOL is None:
10
10
  SHARED_THREAD_POOL = ThreadPoolExecutor()
11
11
 
12
- SHARED_THREAD_POOL.submit(func, bg_task, *args, **kwargs)
12
+ SHARED_THREAD_POOL.submit(func, *args, **kwargs)
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
21
21
  ('updated', models.DateTimeField(auto_now=True)),
22
22
  ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
23
23
  ('name', models.CharField(max_length=1000)),
24
- ('state', models.CharField(choices=[('not_started', 'not_started'), ('running', 'running'), ('success', 'success'), ('partial_success', 'partial_success'), ('failed', 'failed')], default='not_started', max_length=16)),
24
+ ('state', models.CharField(choices=[('not_started', 'not_started'), ('queued', 'queued'), ('running', 'running'), ('success', 'success'), ('partial_success', 'partial_success'), ('failed', 'failed')], default='not_started', max_length=16)),
25
25
  ('steps_to_complete', models.PositiveIntegerField(blank=True, help_text='The number of steps in the task for it to be completed.', null=True)),
26
26
  ('steps_completed', models.PositiveIntegerField(blank=True, help_text='The number of steps completed so far by this task', null=True)),
27
27
  ('started_at', models.DateTimeField(blank=True, null=True)),
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.11 on 2025-02-07 21:57
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("bgtask", "0002_backgroundtask_namespace_alter_backgroundtask_name"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name="backgroundtask",
15
+ name="queued_at",
16
+ field=models.DateTimeField(blank=True, null=True),
17
+ ),
18
+ ]
@@ -13,9 +13,14 @@ from .models import BackgroundTask
13
13
  class BGTaskModelAdmin(admin.ModelAdmin):
14
14
  # This is not overridden to avoid messing with the implicit logic for finding change list
15
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.
16
+ # subclass or you need to extend from this in your custom template (which you can install at
17
+ # <your-app>/templates/admin/<your-app>/<your-model>/change_list.html)
17
18
  #
18
19
  # change_list_template = "bgtask/admin/change_list.html"
20
+ #
21
+ # Set this to tell the admin change list page which background tasks to show in the table.
22
+ #
23
+ # bgtask_names = ["task a", "task b"]
19
24
 
20
25
  # ----------------------------------------------------------------------------------------------
21
26
  # Class API
@@ -41,14 +46,15 @@ class BGTaskModelAdmin(admin.ModelAdmin):
41
46
  # API for subclasses
42
47
  # ----------------------------------------------------------------------------------------------
43
48
  def start_bgtask(self, name, **kwargs):
44
- bgtask = BackgroundTask.objects.create(
45
- name=name,
46
- namespace=self._bgtask_namespace,
47
- **kwargs,
48
- )
49
+ bgtask = self._create_bgtask(name=name, **kwargs)
49
50
  bgtask.start()
50
51
  return bgtask
51
52
 
53
+ def queue_bgtask(self, name, **kwargs):
54
+ bgtask = self._create_bgtask(name=name, **kwargs)
55
+ bgtask.queue()
56
+ return bgtask
57
+
52
58
  # ----------------------------------------------------------------------------------------------
53
59
  # Superclass overrides
54
60
  # ----------------------------------------------------------------------------------------------
@@ -78,6 +84,12 @@ class BGTaskModelAdmin(admin.ModelAdmin):
78
84
 
79
85
  next_action = next_action.__wrapped__
80
86
 
87
+ def _create_bgtask(self, **kwargs):
88
+ return BackgroundTask.objects.create(
89
+ namespace=self._bgtask_namespace,
90
+ **kwargs,
91
+ )
92
+
81
93
  def _admin_bg_tasks(self, request):
82
94
  task_name_to_desc = {}
83
95
  for action, action_name, action_description in self.get_actions(request).values():
@@ -100,12 +112,16 @@ class BGTaskModelAdmin(admin.ModelAdmin):
100
112
  Q(state=BackgroundTask.STATES.running)
101
113
  & Q(started_at__gt=timezone.now() - timedelta(days=1))
102
114
  )
115
+ | (
116
+ Q(state=BackgroundTask.STATES.queued)
117
+ & Q(queued_at__gt=timezone.now() - timedelta(days=1))
118
+ )
103
119
  | (
104
120
  ~Q(state=BackgroundTask.STATES.not_started)
105
121
  & Q(completed_at__gt=timezone.now() - timedelta(hours=2))
106
122
  )
107
123
  )
108
- .order_by("-started_at")
124
+ .order_by("-started_at", "-queued_at")
109
125
  )
110
126
  for bgt in bgts:
111
127
  bgt.admin_description = task_name_to_desc[bgt.name]
@@ -38,13 +38,16 @@ def locked(meth):
38
38
  return _locked_meth
39
39
 
40
40
 
41
- def only_if_state(state):
41
+ def only_if_state(state, no_op_states=frozenset()):
42
42
  def only_if_state_decorator(meth):
43
- def only_if_state_wrapper(self, *args, **kwargs):
44
- if self.state != state:
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:
45
48
  raise RuntimeError(
46
- "%s cannot execute %s as in state %s not %s"
47
- % (self, meth.__name__, self.state, state)
49
+ "%s cannot execute %s as in state %s not one of %s"
50
+ % (self, meth.__name__, self.state, states)
48
51
  )
49
52
  return meth(self, *args, **kwargs)
50
53
 
@@ -72,7 +75,7 @@ class BackgroundTask(models.Model):
72
75
  ),
73
76
  )
74
77
 
75
- STATES = Choices("not_started", "running", "success", "partial_success", "failed")
78
+ STATES = Choices("not_started", "queued", "running", "success", "partial_success", "failed")
76
79
  state = models.CharField(max_length=16, default=STATES.not_started, choices=STATES)
77
80
  steps_to_complete = models.PositiveIntegerField(
78
81
  null=True, blank=True, help_text="The number of steps in the task for it to be completed."
@@ -81,6 +84,7 @@ class BackgroundTask(models.Model):
81
84
  null=True, blank=True, help_text="The number of steps completed so far by this task"
82
85
  )
83
86
 
87
+ queued_at = models.DateTimeField(null=True, blank=True)
84
88
  started_at = models.DateTimeField(null=True, blank=True)
85
89
  completed_at = models.DateTimeField(null=True, blank=True)
86
90
  result = models.JSONField(null=True, blank=True, help_text="The result(s) of the task, if any")
@@ -104,7 +108,12 @@ class BackgroundTask(models.Model):
104
108
  @property
105
109
  def task_dict(self):
106
110
  task_dict = model_to_dict(self)
107
- return {"id": str(self.id), "updated": self.updated.isoformat(), **task_dict}
111
+ return {
112
+ "id": str(self.id),
113
+ "updated": self.updated.isoformat(),
114
+ "position_in_queue": self.get_position_in_queue(),
115
+ **task_dict,
116
+ }
108
117
 
109
118
  @property
110
119
  def num_failed_steps(self):
@@ -114,6 +123,33 @@ class BackgroundTask(models.Model):
114
123
  def incomplete(self):
115
124
  return self.state in [self.STATES.not_started, self.STATES.running]
116
125
 
126
+ def get_position_in_queue(self):
127
+ if self.state != self.STATES.queued:
128
+ return None
129
+
130
+ if (
131
+ BackgroundTask.objects.filter(
132
+ queued_at__gt=self.queued_at,
133
+ )
134
+ .exclude(state=self.STATES.not_started)
135
+ .exclude(state=self.STATES.queued)
136
+ .exists()
137
+ ):
138
+ # More recent tasks have started, assume we're next (either that or we're stuck)
139
+ return 0
140
+
141
+ # Otherwise approximate queue position as the number of tasks that were queued before us
142
+ # and are still in the queue. So if there is nothing in front of us we are 0 in the queue.
143
+ assert self.queued_at
144
+ return BackgroundTask.objects.filter(
145
+ queued_at__lt=self.queued_at, state=self.STATES.queued
146
+ ).count()
147
+
148
+ def set_steps_to_complete(self, steps_to_complete):
149
+ self.steps_to_complete = steps_to_complete
150
+ self.steps_completed = 0
151
+ self.save()
152
+
117
153
  @contextmanager
118
154
  def runs_single_step(self):
119
155
  try:
@@ -132,10 +168,32 @@ class BackgroundTask(models.Model):
132
168
  else:
133
169
  self.succeed()
134
170
 
171
+ @contextmanager
172
+ def fails_if_exception(self):
173
+ try:
174
+ yield
175
+ except Exception as exc:
176
+ self.fail(exc)
177
+ raise
178
+
135
179
  @locked
136
180
  @only_if_state(STATES.not_started)
181
+ def queue(self):
182
+ log.info("Background Task queueing: %s", self.id)
183
+ self.state = self.STATES.queued
184
+ self.queued_at = timezone.now()
185
+ self.save()
186
+
187
+ @locked
188
+ @only_if_state(
189
+ (STATES.not_started, STATES.queued),
190
+ # Allow start() to be called while running so that if a task's subtasks are queued and
191
+ # asynchronous each can call .start() independently so the first one that is executed will
192
+ # start the task.
193
+ no_op_states=(STATES.running,),
194
+ )
137
195
  def start(self):
138
- log.info("%s starting", self)
196
+ log.info("Background Task starting: %s", self.id)
139
197
  self.state = self.STATES.running
140
198
  self.started_at = timezone.now()
141
199
  self.save()
@@ -144,7 +202,7 @@ class BackgroundTask(models.Model):
144
202
  @only_if_state(STATES.running)
145
203
  def fail(self, exc):
146
204
  """Call to indicate a complete and final failure of the task"""
147
- log.info("%s failed: %s", self, exc)
205
+ log.info("Background Task failed: %s %s", self.id, exc)
148
206
  self.state = self.STATES.failed
149
207
  self.completed_at = timezone.now()
150
208
  self.errors.append(
@@ -2,6 +2,7 @@ window.BG_TASK_ADDED = true;
2
2
 
3
3
  const STATE_TO_EMOJI = {
4
4
  "not_started": "⏱️",
5
+ "queued": "⋯",
5
6
  "running": "🏃🏽‍♀️",
6
7
  "success": "✅",
7
8
  "partial_success": "⚠️",
@@ -201,10 +202,14 @@ class TaskProgressDiv {
201
202
  this._showProgress();
202
203
  this._hideState();
203
204
  break;
205
+ case "queued":
206
+ this._hideProgress();
207
+ this._showState();
208
+ break;
204
209
  }
205
210
 
206
211
  const isOutOfDate = (
207
- !["success", "partial_success", "not_started", "failed"].includes(task.state)
212
+ !["success", "queued", "partial_success", "not_started", "failed"].includes(task.state)
208
213
  && (new Date() - task.updated) > OUT_OF_DATE_PERIOD_S * 1000
209
214
  );
210
215
 
@@ -212,11 +217,21 @@ class TaskProgressDiv {
212
217
  this._addTitle("This task has not been updated for a while");
213
218
  }
214
219
 
215
- this.stateEle.innerHTML = `${STATE_TO_EMOJI[task.state] || "❓"}`;
220
+ this._setStateEmoji(task);
216
221
 
217
222
  this.pgstate.update({ max: task.steps_to_complete, value: task.steps_completed, isOutOfDate });
218
223
  }
219
224
 
225
+ _setStateEmoji(task) {
226
+ this.stateEle.innerHTML = `${STATE_TO_EMOJI[task.state] || "❓"}`;
227
+
228
+ switch (task.state) {
229
+ case "queued":
230
+ this.stateEle.innerHTML += ` (${task.position_in_queue})`;
231
+ break;
232
+ }
233
+ }
234
+
220
235
  _addTitle(title) {
221
236
  if (!this.div.title) {
222
237
  this.div.title = title;
@@ -445,7 +460,7 @@ class BGTaskPoller {
445
460
  for (const cbk of (this.taskCallbacks[task.id] || [])) {
446
461
  cbk(task);
447
462
  }
448
- if (task.state !== "running") {
463
+ if (!['running', 'queued'].includes(task.state)) {
449
464
  this.stopMonitoringTask(task.id);
450
465
  }
451
466
  }
@@ -19,8 +19,11 @@
19
19
  <tbody>
20
20
  {% for bgt in admin_bg_tasks %}
21
21
  {% with bgtask=bgt.task_dict %}
22
- <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>
23
- {% include "bgtask/bg_changelist_status_column.html" %}</td></tr>
22
+ <tr class="bgtask-row">
23
+ <td><a href="{% url 'admin:bgtask_backgroundtask_change' bgtask.id %}">{{ bgt.admin_description }}</a></td>
24
+ <td>{{ bgt.started_at|timesince }} ago</td><td>{% include "bgtask/bg_completed_column.html" %}</td>
25
+ <td>{% include "bgtask/bg_changelist_status_column.html" %}</td>
26
+ </tr>
24
27
  {% endwith %}
25
28
  {% endfor %}
26
29
  </tbody>
@@ -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
@@ -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.3.2"
7
+ version = "0.4.1"
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>",
@@ -36,6 +36,8 @@ black = ">=24.2.0"
36
36
  # Sphinx dependencies also in docs/requirements.txt for read the docs to pick up.
37
37
  sphinx = ">=7.1.2"
38
38
  sphinx-rtd-theme = ">=1.3.0"
39
+ psycopg2 = ">=2.9.10"
40
+ colorlog = ">=6.9.0"
39
41
 
40
42
  [tool.poetry.extras]
41
43
  # ℹ️ if you update any of these, be sure to update "all = [...]" as well, so that it's easy to get
@@ -1,11 +0,0 @@
1
- from utils.stack_context import stack_context
2
-
3
- from .models import BackgroundTask
4
-
5
-
6
- __all__ = ["bgtask_context"]
7
-
8
-
9
- @stack_context(logging_param_name="bgtask_id")
10
- def bgtask_context(id):
11
- return BackgroundTask.objects.get(id=id)