django-admin-background-task 0.4.1__tar.gz → 0.4.4__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.4.1 → django_admin_background_task-0.4.4}/PKG-INFO +1 -1
  2. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/model_admin.py +2 -1
  3. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/models.py +63 -61
  4. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templates/bgtask/admin/change_list.html +1 -1
  5. django_admin_background_task-0.4.4/bgtask/utils.py +56 -0
  6. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/views.py +7 -12
  7. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/pyproject.toml +2 -1
  8. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/LICENSE +0 -0
  9. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/README.md +0 -0
  10. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/__init__.py +0 -0
  11. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/admin.py +0 -0
  12. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/apps.py +0 -0
  13. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/backends/__init__.py +0 -0
  14. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/backends/thread_pool.py +0 -0
  15. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/decorators.py +0 -0
  16. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/migrations/0001_initial.py +0 -0
  17. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/migrations/0002_backgroundtask_namespace_alter_backgroundtask_name.py +0 -0
  18. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/migrations/0003_backgroundtask_queued_at.py +0 -0
  19. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/migrations/__init__.py +0 -0
  20. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/static/bgtask/js/bgtask-once.js +0 -0
  21. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/static/bgtask/js/bgtask.js +0 -0
  22. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templates/bgtask/bg_changelist_status_column.html +0 -0
  23. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templates/bgtask/bg_completed_column.html +0 -0
  24. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templates/bgtask/bgtask_templates.html +0 -0
  25. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templates/bgtask/bgtask_view.html +0 -0
  26. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templates/bgtask/progress.html +0 -0
  27. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templatetags/__init__.py +0 -0
  28. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/templatetags/bgtask.py +0 -0
  29. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/bgtask/tests/test_bgtask.py +0 -0
  30. {django_admin_background_task-0.4.1 → django_admin_background_task-0.4.4}/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.1
3
+ Version: 0.4.4
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
@@ -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
 
@@ -1,4 +1,4 @@
1
- import functools
1
+ import collections
2
2
  import logging
3
3
  import os
4
4
  import time
@@ -8,63 +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, 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)
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
53
45
 
54
- 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
55
51
 
56
- return only_if_state_decorator
52
+ return self
57
53
 
58
54
 
59
55
  class BackgroundTask(models.Model):
60
56
  id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4)
61
- name = models.CharField(
62
- max_length=1000,
63
- help_text=(
64
- "Name (or type) of this task, is not unique "
65
- "per task instance but generally per task functionality"
66
- ),
67
- )
68
57
  namespace = models.CharField(
69
58
  max_length=1000,
70
59
  default="",
@@ -74,6 +63,13 @@ class BackgroundTask(models.Model):
74
63
  "entire codebase, allowing them to be shorter and human readable"
75
64
  ),
76
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
+ )
77
73
 
78
74
  STATES = Choices("not_started", "queued", "running", "success", "partial_success", "failed")
79
75
  state = models.CharField(max_length=16, default=STATES.not_started, choices=STATES)
@@ -102,16 +98,22 @@ class BackgroundTask(models.Model):
102
98
  created = models.DateTimeField(auto_now_add=True)
103
99
  updated = models.DateTimeField(auto_now=True)
104
100
 
101
+ objects = models.Manager.from_queryset(BackgroundTaskQuerySet)()
102
+
105
103
  class Meta:
106
104
  ordering = ["created", "id"]
107
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
+
108
110
  @property
109
111
  def task_dict(self):
110
112
  task_dict = model_to_dict(self)
111
113
  return {
112
114
  "id": str(self.id),
113
115
  "updated": self.updated.isoformat(),
114
- "position_in_queue": self.get_position_in_queue(),
116
+ "position_in_queue": self.position_in_queue,
115
117
  **task_dict,
116
118
  }
117
119
 
@@ -123,27 +125,27 @@ class BackgroundTask(models.Model):
123
125
  def incomplete(self):
124
126
  return self.state in [self.STATES.not_started, self.STATES.running]
125
127
 
126
- def get_position_in_queue(self):
127
- if self.state != self.STATES.queued:
128
- 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
+ )
129
137
 
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()
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")
137
146
  ):
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
+ queued_by_nsn[(task.namespace, task.name)].append(task)
148
+ return queued_by_nsn
147
149
 
148
150
  def set_steps_to_complete(self, steps_to_complete):
149
151
  self.steps_to_complete = steps_to_complete
@@ -199,7 +201,7 @@ class BackgroundTask(models.Model):
199
201
  self.save()
200
202
 
201
203
  @locked
202
- @only_if_state(STATES.running)
204
+ @only_if_state((STATES.queued, STATES.running))
203
205
  def fail(self, exc):
204
206
  """Call to indicate a complete and final failure of the task"""
205
207
  log.info("Background Task failed: %s %s", self.id, exc)
@@ -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>
@@ -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
- "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
 
@@ -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.1"
7
+ version = "0.4.4"
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