django-database-task 0.3.0__tar.gz → 0.3.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 (39) hide show
  1. {django_database_task-0.3.0/django_database_task.egg-info → django_database_task-0.3.1}/PKG-INFO +1 -1
  2. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/__init__.py +1 -1
  3. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/views.py +72 -29
  4. {django_database_task-0.3.0 → django_database_task-0.3.1/django_database_task.egg-info}/PKG-INFO +1 -1
  5. {django_database_task-0.3.0 → django_database_task-0.3.1}/pyproject.toml +1 -1
  6. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_views.py +168 -0
  7. {django_database_task-0.3.0 → django_database_task-0.3.1}/LICENSE +0 -0
  8. {django_database_task-0.3.0 → django_database_task-0.3.1}/MANIFEST.in +0 -0
  9. {django_database_task-0.3.0 → django_database_task-0.3.1}/README.md +0 -0
  10. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/admin.py +0 -0
  11. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/apps.py +0 -0
  12. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/backends.py +0 -0
  13. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/__init__.py +0 -0
  14. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/auth.py +0 -0
  15. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/backend.py +0 -0
  16. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/cloudtasks/detection.py +0 -0
  17. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/executor.py +0 -0
  18. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/locale/ja/LC_MESSAGES/django.mo +0 -0
  19. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/locale/ja/LC_MESSAGES/django.po +0 -0
  20. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/__init__.py +0 -0
  21. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/commands/__init__.py +0 -0
  22. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/commands/purge_completed_database_tasks.py +0 -0
  23. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/management/commands/run_database_tasks.py +0 -0
  24. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/migrations/0001_initial.py +0 -0
  25. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/migrations/__init__.py +0 -0
  26. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/models.py +0 -0
  27. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/shutdown.py +0 -0
  28. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task/urls.py +0 -0
  29. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/SOURCES.txt +0 -0
  30. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/dependency_links.txt +0 -0
  31. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/requires.txt +0 -0
  32. {django_database_task-0.3.0 → django_database_task-0.3.1}/django_database_task.egg-info/top_level.txt +0 -0
  33. {django_database_task-0.3.0 → django_database_task-0.3.1}/setup.cfg +0 -0
  34. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_admin.py +0 -0
  35. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_backend.py +0 -0
  36. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_commands.py +0 -0
  37. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_executor.py +0 -0
  38. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_models.py +0 -0
  39. {django_database_task-0.3.0 → django_database_task-0.3.1}/tests/test_shutdown.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-database-task
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A database-backed task queue backend for Django's built-in task framework
5
5
  Author-email: Shinya Okano <tokibito@gmail.com>
6
6
  Maintainer-email: Shinya Okano <tokibito@gmail.com>
@@ -2,7 +2,7 @@
2
2
  django-database-task: A database-backed task queue backend for Django's task framework.
3
3
  """
4
4
 
5
- __version__ = "0.3.0"
5
+ __version__ = "0.3.1"
6
6
 
7
7
  _EXECUTOR_EXPORTS = (
8
8
  "fetch_task",
@@ -39,13 +39,78 @@ def get_backend(backend_name="default"):
39
39
  """Get a task backend by name."""
40
40
  if backend_name == "default":
41
41
  return default_task_backend
42
- from django.tasks import tasks
42
+ from django.tasks import task_backends
43
43
 
44
- return tasks[backend_name]
44
+ return task_backends[backend_name]
45
+
46
+
47
+ class BackendAuthMixin:
48
+ """
49
+ Apply the authentication handler the task backend provides, if any.
50
+
51
+ The backend decides how requests are authenticated by returning a
52
+ handler from get_auth_handler() (see
53
+ DatabaseTaskBackend.get_auth_handler). A handler takes the request and
54
+ returns None to accept it, or a response to reject it.
55
+
56
+ The check runs in dispatch(), before the method is dispatched, so every
57
+ endpoint and every HTTP method is covered. Backends that provide no
58
+ handler are unaffected.
59
+ """
60
+
61
+ def get_backend_name(self, request, *args, **kwargs):
62
+ """
63
+ Name of the backend that authenticates this request.
64
+
65
+ Views that read backend_name from a request body override this.
66
+ """
67
+ return request.GET.get("backend_name", "default")
68
+
69
+ def get_auth_error_response(self, request, *args, **kwargs):
70
+ """Return a response if authentication fails, None if it passes."""
71
+ backend_name = self.get_backend_name(request, *args, **kwargs)
72
+ try:
73
+ backend = get_backend(backend_name)
74
+ except Exception:
75
+ # An unusable backend name must not skip authentication.
76
+ return JsonResponse({"error": "Invalid backend name"}, status=400)
77
+
78
+ get_auth_handler = getattr(backend, "get_auth_handler", None)
79
+ if get_auth_handler is None:
80
+ return None
81
+
82
+ auth_handler = get_auth_handler()
83
+ if auth_handler is None:
84
+ return None
85
+
86
+ return auth_handler(request)
87
+
88
+ def dispatch(self, request, *args, **kwargs):
89
+ error_response = self.get_auth_error_response(request, *args, **kwargs)
90
+ if error_response:
91
+ return error_response
92
+ return super().dispatch(request, *args, **kwargs)
93
+
94
+
95
+ class JSONBodyBackendAuthMixin(BackendAuthMixin):
96
+ """BackendAuthMixin for views that read backend_name from a JSON body."""
97
+
98
+ def get_backend_name(self, request, *args, **kwargs):
99
+ try:
100
+ data = json.loads(request.body) if request.body else {}
101
+ except json.JSONDecodeError:
102
+ # Let the view report the malformed body.
103
+ return "default"
104
+
105
+ if not isinstance(data, dict):
106
+ return "default"
107
+
108
+ backend_name = data.get("backend_name", "default")
109
+ return backend_name if isinstance(backend_name, str) else "default"
45
110
 
46
111
 
47
112
  @method_decorator(csrf_exempt, name="dispatch")
48
- class RunTasksView(View):
113
+ class RunTasksView(JSONBodyBackendAuthMixin, View):
49
114
  """
50
115
  Process pending tasks via HTTP POST.
51
116
 
@@ -132,7 +197,7 @@ class RunTasksView(View):
132
197
 
133
198
 
134
199
  @method_decorator(csrf_exempt, name="dispatch")
135
- class RunOneTaskView(View):
200
+ class RunOneTaskView(JSONBodyBackendAuthMixin, View):
136
201
  """
137
202
  Process a single pending task via HTTP POST.
138
203
 
@@ -196,7 +261,7 @@ class RunOneTaskView(View):
196
261
  )
197
262
 
198
263
 
199
- class TaskStatusView(View):
264
+ class TaskStatusView(BackendAuthMixin, View):
200
265
  """
201
266
  Get pending task count via HTTP GET.
202
267
 
@@ -225,7 +290,7 @@ class TaskStatusView(View):
225
290
 
226
291
 
227
292
  @method_decorator(csrf_exempt, name="dispatch")
228
- class ExecuteTaskView(View):
293
+ class ExecuteTaskView(BackendAuthMixin, View):
229
294
  """
230
295
  Execute a specific task by ID via HTTP POST.
231
296
 
@@ -316,17 +381,6 @@ class ExecuteTaskView(View):
316
381
  http_method_names = ["post"]
317
382
 
318
383
  def post(self, request, task_id):
319
- backend_name = request.GET.get("backend_name", "default")
320
-
321
- # Get backend and check for auth handler
322
- backend = get_backend(backend_name)
323
- if hasattr(backend, "get_auth_handler"):
324
- auth_handler = backend.get_auth_handler()
325
- if auth_handler:
326
- error_response = auth_handler(request)
327
- if error_response:
328
- return error_response
329
-
330
384
  fail_on_error = request.GET.get("fail_on_error", "").lower() == "true"
331
385
  allow_retry = request.GET.get("allow_retry", "").lower() == "true"
332
386
 
@@ -364,7 +418,7 @@ class ExecuteTaskView(View):
364
418
 
365
419
 
366
420
  @method_decorator(csrf_exempt, name="dispatch")
367
- class PurgeCompletedTasksView(View):
421
+ class PurgeCompletedTasksView(BackendAuthMixin, View):
368
422
  """
369
423
  Delete completed tasks via HTTP GET or POST.
370
424
 
@@ -452,17 +506,6 @@ class PurgeCompletedTasksView(View):
452
506
 
453
507
  def _purge(self, request):
454
508
  """Common purge logic for GET and POST."""
455
- backend_name = request.GET.get("backend_name", "default")
456
-
457
- # Get backend and check for auth handler
458
- backend = get_backend(backend_name)
459
- if hasattr(backend, "get_auth_handler"):
460
- auth_handler = backend.get_auth_handler()
461
- if auth_handler:
462
- error_response = auth_handler(request)
463
- if error_response:
464
- return error_response
465
-
466
509
  # Get parameters
467
510
  params, error = self._get_params(request)
468
511
  if error:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-database-task
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A database-backed task queue backend for Django's built-in task framework
5
5
  Author-email: Shinya Okano <tokibito@gmail.com>
6
6
  Maintainer-email: Shinya Okano <tokibito@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-database-task"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "A database-backed task queue backend for Django's built-in task framework"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -3,11 +3,13 @@
3
3
  import json
4
4
 
5
5
  import pytest
6
+ from django.http import JsonResponse
6
7
  from django.tasks.base import TaskResultStatus
7
8
  from django.test import Client
8
9
  from django.urls import reverse
9
10
  from django.utils import timezone
10
11
 
12
+ from django_database_task.backends import DatabaseTaskBackend
11
13
  from django_database_task.models import DatabaseTask
12
14
 
13
15
 
@@ -853,3 +855,169 @@ class TestPurgeCompletedTasksView:
853
855
 
854
856
  assert response.status_code == 400
855
857
  assert "No valid statuses" in response.json()["error"]
858
+
859
+
860
+ # All endpoints that must consult the backend's authentication handler,
861
+ # as (url name, HTTP method, reverse() args).
862
+ AUTHENTICATED_ENDPOINTS = [
863
+ ("run_tasks", "post", []),
864
+ ("run_one_task", "post", []),
865
+ ("task_status", "get", []),
866
+ ("execute_task", "post", ["3f2a9c11-0000-4000-8000-000000000000"]),
867
+ ("purge_completed_tasks", "get", []),
868
+ ("purge_completed_tasks", "post", []),
869
+ ]
870
+
871
+
872
+ class AuthHandlerRecorder:
873
+ """An authentication handler that records the requests it saw."""
874
+
875
+ def __init__(self, response=None):
876
+ self.requests = []
877
+ self.response = response
878
+
879
+ def __call__(self, request):
880
+ self.requests.append(request)
881
+ return self.response
882
+
883
+
884
+ @pytest.fixture
885
+ def rejecting_handler(monkeypatch):
886
+ """Make the default backend reject every request with a 401."""
887
+ recorder = AuthHandlerRecorder(JsonResponse({"error": "Unauthorized"}, status=401))
888
+ monkeypatch.setattr(DatabaseTaskBackend, "get_auth_handler", lambda self: recorder)
889
+ return recorder
890
+
891
+
892
+ @pytest.fixture
893
+ def accepting_handler(monkeypatch):
894
+ """Make the default backend accept every request."""
895
+ recorder = AuthHandlerRecorder(None)
896
+ monkeypatch.setattr(DatabaseTaskBackend, "get_auth_handler", lambda self: recorder)
897
+ return recorder
898
+
899
+
900
+ def _call(client, url_name, method, args):
901
+ url = reverse(f"django_database_task:{url_name}", args=args)
902
+ return getattr(client, method)(url, data="", content_type="application/json")
903
+
904
+
905
+ @pytest.mark.django_db
906
+ class TestBackendAuthentication:
907
+ """Tests for the authentication handler the backend provides."""
908
+
909
+ def test_base_backend_provides_no_handler(self):
910
+ """The base backend opts out of authentication."""
911
+ assert (
912
+ DatabaseTaskBackend(alias="default", params={}).get_auth_handler() is None
913
+ )
914
+
915
+ @pytest.mark.parametrize("url_name,method,args", AUTHENTICATED_ENDPOINTS)
916
+ def test_rejected_request_is_blocked(
917
+ self, client, rejecting_handler, url_name, method, args
918
+ ):
919
+ """Every endpoint returns the handler's response and stops there."""
920
+ response = _call(client, url_name, method, args)
921
+
922
+ assert response.status_code == 401
923
+ assert response.json() == {"error": "Unauthorized"}
924
+ assert len(rejecting_handler.requests) == 1
925
+
926
+ @pytest.mark.parametrize("url_name,method,args", AUTHENTICATED_ENDPOINTS)
927
+ def test_accepted_request_reaches_the_view(
928
+ self, client, accepting_handler, url_name, method, args
929
+ ):
930
+ """A handler returning None lets the request through."""
931
+ response = _call(client, url_name, method, args)
932
+
933
+ assert len(accepting_handler.requests) == 1
934
+ assert response.status_code != 401
935
+
936
+ @pytest.mark.parametrize("url_name,method,args", AUTHENTICATED_ENDPOINTS)
937
+ def test_endpoints_work_without_a_handler(self, client, url_name, method, args):
938
+ """Backends without a handler are unaffected."""
939
+ response = _call(client, url_name, method, args)
940
+
941
+ assert response.status_code != 401
942
+
943
+ def test_rejected_request_does_not_run_tasks(self, client, rejecting_handler):
944
+ """A blocked run request leaves the queued task alone."""
945
+ task = DatabaseTask.objects.create(
946
+ task_path="tests.test_executor.sample_task",
947
+ queue_name="default",
948
+ priority=0,
949
+ args_json=[],
950
+ kwargs_json={},
951
+ status=TaskResultStatus.READY,
952
+ enqueued_at=timezone.now(),
953
+ backend_name="default",
954
+ )
955
+
956
+ response = client.post(reverse("django_database_task:run_tasks"))
957
+
958
+ assert response.status_code == 401
959
+ task.refresh_from_db()
960
+ assert task.status == TaskResultStatus.READY
961
+
962
+ def test_rejected_request_does_not_purge_tasks(self, client, rejecting_handler):
963
+ """A blocked purge request deletes nothing."""
964
+ DatabaseTask.objects.create(
965
+ task_path="tests.test_executor.sample_task",
966
+ queue_name="default",
967
+ priority=0,
968
+ args_json=[],
969
+ kwargs_json={},
970
+ status=TaskResultStatus.SUCCESSFUL,
971
+ enqueued_at=timezone.now(),
972
+ finished_at=timezone.now(),
973
+ backend_name="default",
974
+ )
975
+
976
+ response = client.post(reverse("django_database_task:purge_completed_tasks"))
977
+
978
+ assert response.status_code == 401
979
+ assert DatabaseTask.objects.count() == 1
980
+
981
+ def test_unusable_backend_name_does_not_skip_authentication(
982
+ self, client, rejecting_handler
983
+ ):
984
+ """An unresolvable backend is rejected instead of running unauthenticated."""
985
+ response = client.get(
986
+ reverse("django_database_task:task_status"),
987
+ {"backend_name": "does-not-exist"},
988
+ )
989
+
990
+ assert response.status_code == 400
991
+ assert response.json() == {"error": "Invalid backend name"}
992
+ assert rejecting_handler.requests == []
993
+
994
+ def test_backend_name_from_the_request_body_is_used(self, client, monkeypatch):
995
+ """Views that read backend_name from a JSON body authenticate with it."""
996
+ seen = []
997
+ monkeypatch.setattr(
998
+ DatabaseTaskBackend,
999
+ "get_auth_handler",
1000
+ lambda self: lambda request: seen.append(request) or None,
1001
+ )
1002
+
1003
+ response = client.post(
1004
+ reverse("django_database_task:run_tasks"),
1005
+ data=json.dumps({"backend_name": "default"}),
1006
+ content_type="application/json",
1007
+ )
1008
+
1009
+ assert response.status_code == 200
1010
+ assert len(seen) == 1
1011
+
1012
+ def test_malformed_body_still_reports_a_bad_request(
1013
+ self, client, accepting_handler
1014
+ ):
1015
+ """A broken JSON body is reported by the view, not the auth check."""
1016
+ response = client.post(
1017
+ reverse("django_database_task:run_tasks"),
1018
+ data="{not json",
1019
+ content_type="application/json",
1020
+ )
1021
+
1022
+ assert response.status_code == 400
1023
+ assert "Invalid JSON" in response.json()["error"]