django-lambda-tasks 0.4.0__tar.gz → 0.4.2__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 (101) hide show
  1. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/PKG-INFO +1 -1
  2. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/admin.py +22 -1
  3. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/local_executor.py +15 -2
  4. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/pyproject.toml +1 -1
  5. django_lambda_tasks-0.4.2/tests/test_admin.py +282 -0
  6. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_local_executor.py +19 -2
  7. django_lambda_tasks-0.4.0/tests/test_admin.py +0 -21
  8. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.github/workflows/ci.yml +0 -0
  9. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.github/workflows/release.yml +0 -0
  10. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.gitignore +0 -0
  11. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  12. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/async-local-execution/design.md +0 -0
  13. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/async-local-execution/requirements.md +0 -0
  14. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/async-local-execution/tasks.md +0 -0
  15. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  16. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  17. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  18. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  19. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  20. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  21. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  22. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  23. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  24. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  25. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  26. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  27. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  28. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  29. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  30. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  31. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/retry-delay/.config.kiro +0 -0
  32. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/retry-delay/design.md +0 -0
  33. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/retry-delay/requirements.md +0 -0
  34. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/retry-delay/tasks.md +0 -0
  35. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  36. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks/design.md +0 -0
  37. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  38. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  39. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  40. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  41. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  42. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  43. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/singleton-task/.config.kiro +0 -0
  44. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/singleton-task/design.md +0 -0
  45. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/singleton-task/requirements.md +0 -0
  46. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/singleton-task/tasks.md +0 -0
  47. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  48. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  49. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  50. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  51. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/task-retry/.config.kiro +0 -0
  52. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/task-retry/design.md +0 -0
  53. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/task-retry/requirements.md +0 -0
  54. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/specs/task-retry/tasks.md +0 -0
  55. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/steering/product.md +0 -0
  56. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/steering/structure.md +0 -0
  57. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.kiro/steering/tech.md +0 -0
  58. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.pre-commit-config.yaml +0 -0
  59. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/.vscode/settings.json +0 -0
  60. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/README.md +0 -0
  61. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/README.md +0 -0
  62. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_app/__init__.py +0 -0
  63. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_app/apps.py +0 -0
  64. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_app/tasks.py +0 -0
  65. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_app/urls.py +0 -0
  66. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_app/views.py +0 -0
  67. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_project/__init__.py +0 -0
  68. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_project/settings.py +0 -0
  69. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_project/urls.py +0 -0
  70. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/example_project/wsgi.py +0 -0
  71. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/example/manage.py +0 -0
  72. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/__init__.py +0 -0
  73. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/apps.py +0 -0
  74. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/decorators.py +0 -0
  75. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/environment_loader.py +0 -0
  76. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/handler.py +0 -0
  77. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/logging.py +0 -0
  78. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/migrations/0001_initial.py +0 -0
  79. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  80. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/migrations/__init__.py +0 -0
  81. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/models.py +0 -0
  82. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/secret_loader.py +0 -0
  83. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/settings.py +0 -0
  84. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/tasks.py +0 -0
  85. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/lambda_tasks/timeouts.py +0 -0
  86. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/conftest.py +0 -0
  87. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/settings.py +0 -0
  88. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_decorator.py +0 -0
  89. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_decorators.py +0 -0
  90. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_deferred_enqueue.py +0 -0
  91. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_environment_loader.py +0 -0
  92. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_handler.py +0 -0
  93. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_kwargs_only.py +0 -0
  94. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_logging.py +0 -0
  95. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_models.py +0 -0
  96. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_secret_loader.py +0 -0
  97. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_serializer.py +0 -0
  98. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_settings.py +0 -0
  99. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_tasks.py +0 -0
  100. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_timeout_validation.py +0 -0
  101. {django_lambda_tasks-0.4.0 → django_lambda_tasks-0.4.2}/tests/test_timeouts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-lambda-tasks
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: awslambdaric
@@ -3,8 +3,13 @@
3
3
  from django.contrib import admin
4
4
  from django.db.models import DurationField, ExpressionWrapper, F, QuerySet
5
5
  from django.http import HttpRequest
6
+ from django.utils.module_loading import import_string
6
7
 
7
- from lambda_tasks.models import TaskRecord
8
+ from lambda_tasks.models import (
9
+ SQSLambdaTask,
10
+ SQSLambdaTaskMessage,
11
+ TaskRecord,
12
+ )
8
13
 
9
14
 
10
15
  @admin.register(TaskRecord)
@@ -33,6 +38,7 @@ class TaskRecordAdmin(admin.ModelAdmin):
33
38
  "result",
34
39
  "traceback",
35
40
  )
41
+ actions = ["replay_tasks"]
36
42
 
37
43
  def get_queryset(self, request: HttpRequest) -> QuerySet:
38
44
  return (
@@ -52,3 +58,18 @@ class TaskRecordAdmin(admin.ModelAdmin):
52
58
  return None
53
59
  total_seconds = d.total_seconds()
54
60
  return f"{total_seconds:.3f}s"
61
+
62
+ @admin.action(description="Replay selected tasks", permissions=("change",))
63
+ def replay_tasks(self, request: HttpRequest, queryset: QuerySet) -> None:
64
+ for record in queryset:
65
+ queue = import_string(record.task_name).queue
66
+ task = SQSLambdaTask(
67
+ message=SQSLambdaTaskMessage(
68
+ task_name=record.task_name,
69
+ kwargs=record.kwargs,
70
+ n_retries=0,
71
+ ),
72
+ delay=0,
73
+ queue=queue,
74
+ )
75
+ task.execute_on_commit()
@@ -1,10 +1,13 @@
1
1
  """Process pool executor for async local task execution."""
2
2
 
3
+ import logging
3
4
  import uuid
4
- from concurrent.futures import ProcessPoolExecutor
5
+ from concurrent.futures import Future, ProcessPoolExecutor
5
6
 
6
7
  from lambda_tasks.settings import LambdaTasksSettings
7
8
 
9
+ logger = logging.getLogger(__name__)
10
+
8
11
  _pool: ProcessPoolExecutor | None = None
9
12
 
10
13
 
@@ -38,8 +41,18 @@ def _execute_in_worker(*, message_json: str, message_id: str) -> None:
38
41
  message.execute_immediately(message_id=message_id)
39
42
 
40
43
 
44
+ def _log_worker_exception(future: Future) -> None: # type: ignore[type-arg]
45
+ """Callback attached to each worker future. Logs unhandled exceptions."""
46
+ exception = future.exception()
47
+ if exception is not None:
48
+ logger.error("Worker process raised an exception", exc_info=exception)
49
+
50
+
41
51
  def submit_task(*, message_json: str) -> None:
42
52
  """Submit a task to the process pool. Fire-and-forget."""
43
53
  pool = get_pool()
44
54
  message_id = str(uuid.uuid4())
45
- pool.submit(_execute_in_worker, message_json=message_json, message_id=message_id)
55
+ future = pool.submit(
56
+ _execute_in_worker, message_json=message_json, message_id=message_id
57
+ )
58
+ future.add_done_callback(_log_worker_exception)
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.4.0"
10
+ version = "0.4.2"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
@@ -0,0 +1,282 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+ from django.contrib import admin
5
+ from django.contrib.auth.models import Permission, User
6
+ from django.contrib.contenttypes.models import ContentType
7
+ from django.test import RequestFactory
8
+ from django.utils.timezone import now
9
+
10
+ import lambda_tasks.admin # noqa: F401 — triggers @admin.register side-effect
11
+ from lambda_tasks.models import SQSLambdaTask, TaskRecord, TaskStatus
12
+
13
+
14
+ def test_task_record_registered_in_admin():
15
+ assert TaskRecord in admin.site._registry
16
+
17
+
18
+ def test_task_record_admin_list_display():
19
+ assert admin.site._registry[TaskRecord].list_display == (
20
+ "pk",
21
+ "task_name",
22
+ "status",
23
+ "start_time",
24
+ "end_time",
25
+ "n_retries",
26
+ "duration",
27
+ "result",
28
+ )
29
+
30
+
31
+ @pytest.fixture()
32
+ def admin_user(db: None) -> User:
33
+ return User.objects.create_superuser(
34
+ username="admin", password="password", email="admin@example.com"
35
+ )
36
+
37
+
38
+ @pytest.fixture()
39
+ def task_record(db: None) -> TaskRecord:
40
+ return TaskRecord.objects.create(
41
+ id="00000000-0000-0000-0000-000000000001",
42
+ task_name="myapp.tasks.my_task",
43
+ kwargs={"user_id": 42},
44
+ n_retries=3,
45
+ status=TaskStatus.FAILED,
46
+ start_time=now(),
47
+ end_time=now(),
48
+ )
49
+
50
+
51
+ @pytest.fixture()
52
+ def _mock_import_string():
53
+ class _FakeWrapper:
54
+ queue = "default"
55
+
56
+ with patch("lambda_tasks.admin.import_string", return_value=_FakeWrapper()):
57
+ yield
58
+
59
+
60
+ @pytest.mark.django_db()
61
+ class TestReplayAction:
62
+ def test_replay_action_is_registered(self) -> None:
63
+ model_admin = admin.site._registry[TaskRecord]
64
+ request = RequestFactory().post("/")
65
+ request.user = User(is_staff=True, is_superuser=True)
66
+ action_names = list(model_admin.get_actions(request).keys())
67
+ assert "replay_tasks" in action_names
68
+
69
+ def test_replay_enqueues_task_with_original_kwargs(
70
+ self, admin_user: User, task_record: TaskRecord, _mock_import_string: None
71
+ ) -> None:
72
+ model_admin = admin.site._registry[TaskRecord]
73
+ request = RequestFactory().post("/")
74
+ request.user = admin_user
75
+
76
+ queryset = TaskRecord.objects.filter(pk=task_record.pk)
77
+
78
+ with patch(
79
+ "lambda_tasks.admin.SQSLambdaTask.execute_on_commit"
80
+ ) as mock_execute:
81
+ model_admin.replay_tasks(request, queryset)
82
+
83
+ mock_execute.assert_called_once()
84
+
85
+ def test_replay_resets_n_retries_to_zero(
86
+ self, admin_user: User, task_record: TaskRecord, _mock_import_string: None
87
+ ) -> None:
88
+ model_admin = admin.site._registry[TaskRecord]
89
+ request = RequestFactory().post("/")
90
+ request.user = admin_user
91
+
92
+ queryset = TaskRecord.objects.filter(pk=task_record.pk)
93
+
94
+ built_tasks: list[SQSLambdaTask] = []
95
+
96
+ def capture_execute(self: SQSLambdaTask) -> None:
97
+ built_tasks.append(self)
98
+
99
+ with patch.object(SQSLambdaTask, "execute_on_commit", capture_execute):
100
+ model_admin.replay_tasks(request, queryset)
101
+
102
+ assert len(built_tasks) == 1
103
+ assert built_tasks[0].message.n_retries == 0
104
+
105
+ def test_replay_preserves_task_name(
106
+ self, admin_user: User, task_record: TaskRecord, _mock_import_string: None
107
+ ) -> None:
108
+ model_admin = admin.site._registry[TaskRecord]
109
+ request = RequestFactory().post("/")
110
+ request.user = admin_user
111
+
112
+ queryset = TaskRecord.objects.filter(pk=task_record.pk)
113
+
114
+ built_tasks: list[SQSLambdaTask] = []
115
+
116
+ def capture_execute(self: SQSLambdaTask) -> None:
117
+ built_tasks.append(self)
118
+
119
+ with patch.object(SQSLambdaTask, "execute_on_commit", capture_execute):
120
+ model_admin.replay_tasks(request, queryset)
121
+
122
+ assert built_tasks[0].message.task_name == "myapp.tasks.my_task"
123
+ assert built_tasks[0].message.kwargs == {"user_id": 42}
124
+
125
+ def test_replay_uses_delay_zero(
126
+ self, admin_user: User, task_record: TaskRecord, _mock_import_string: None
127
+ ) -> None:
128
+ model_admin = admin.site._registry[TaskRecord]
129
+ request = RequestFactory().post("/")
130
+ request.user = admin_user
131
+
132
+ queryset = TaskRecord.objects.filter(pk=task_record.pk)
133
+
134
+ built_tasks: list[SQSLambdaTask] = []
135
+
136
+ def capture_execute(self: SQSLambdaTask) -> None:
137
+ built_tasks.append(self)
138
+
139
+ with patch.object(SQSLambdaTask, "execute_on_commit", capture_execute):
140
+ model_admin.replay_tasks(request, queryset)
141
+
142
+ assert built_tasks[0].delay == 0
143
+
144
+ def test_replay_resolves_queue_from_wrapper(
145
+ self, admin_user: User, db: None
146
+ ) -> None:
147
+ record = TaskRecord.objects.create(
148
+ id="00000000-0000-0000-0000-000000000002",
149
+ task_name="myapp.tasks.custom_queue_task",
150
+ kwargs={"x": 1},
151
+ n_retries=0,
152
+ status=TaskStatus.FAILED,
153
+ start_time=now(),
154
+ end_time=now(),
155
+ )
156
+
157
+ model_admin = admin.site._registry[TaskRecord]
158
+ request = RequestFactory().post("/")
159
+ request.user = admin_user
160
+
161
+ queryset = TaskRecord.objects.filter(pk=record.pk)
162
+
163
+ built_tasks: list[SQSLambdaTask] = []
164
+
165
+ def capture_execute(self: SQSLambdaTask) -> None:
166
+ built_tasks.append(self)
167
+
168
+ class _FakeWrapper:
169
+ queue = "high-priority"
170
+
171
+ with patch.object(SQSLambdaTask, "execute_on_commit", capture_execute):
172
+ with patch(
173
+ "lambda_tasks.admin.import_string",
174
+ return_value=_FakeWrapper(),
175
+ ):
176
+ model_admin.replay_tasks(request, queryset)
177
+
178
+ assert built_tasks[0].queue == "high-priority"
179
+
180
+ def test_replay_raises_on_import_error(
181
+ self, admin_user: User, task_record: TaskRecord
182
+ ) -> None:
183
+ model_admin = admin.site._registry[TaskRecord]
184
+ request = RequestFactory().post("/")
185
+ request.user = admin_user
186
+
187
+ queryset = TaskRecord.objects.filter(pk=task_record.pk)
188
+
189
+ with patch(
190
+ "lambda_tasks.admin.import_string",
191
+ side_effect=ImportError("no such module"),
192
+ ):
193
+ with pytest.raises(ImportError, match="no such module"):
194
+ model_admin.replay_tasks(request, queryset)
195
+
196
+ def test_replay_raises_when_not_wrapper(
197
+ self, admin_user: User, task_record: TaskRecord
198
+ ) -> None:
199
+ model_admin = admin.site._registry[TaskRecord]
200
+ request = RequestFactory().post("/")
201
+ request.user = admin_user
202
+
203
+ queryset = TaskRecord.objects.filter(pk=task_record.pk)
204
+
205
+ with patch(
206
+ "lambda_tasks.admin.import_string",
207
+ return_value="not a wrapper",
208
+ ):
209
+ with pytest.raises(AttributeError):
210
+ model_admin.replay_tasks(request, queryset)
211
+
212
+ def test_replay_multiple_records(
213
+ self, admin_user: User, db: None, _mock_import_string: None
214
+ ) -> None:
215
+ records = [
216
+ TaskRecord.objects.create(
217
+ id=f"00000000-0000-0000-0000-00000000000{i}",
218
+ task_name=f"myapp.tasks.task_{i}",
219
+ kwargs={"key": i},
220
+ n_retries=0,
221
+ status=TaskStatus.FAILED,
222
+ start_time=now(),
223
+ end_time=now(),
224
+ )
225
+ for i in range(3)
226
+ ]
227
+
228
+ model_admin = admin.site._registry[TaskRecord]
229
+ request = RequestFactory().post("/")
230
+ request.user = admin_user
231
+
232
+ queryset = TaskRecord.objects.filter(pk__in=[r.pk for r in records])
233
+
234
+ built_tasks: list[SQSLambdaTask] = []
235
+
236
+ def capture_execute(self: SQSLambdaTask) -> None:
237
+ built_tasks.append(self)
238
+
239
+ with patch.object(SQSLambdaTask, "execute_on_commit", capture_execute):
240
+ model_admin.replay_tasks(request, queryset)
241
+
242
+ assert len(built_tasks) == 3
243
+ task_names = {t.message.task_name for t in built_tasks}
244
+ assert task_names == {
245
+ "myapp.tasks.task_0",
246
+ "myapp.tasks.task_1",
247
+ "myapp.tasks.task_2",
248
+ }
249
+
250
+ def test_replay_action_hidden_without_change_permission(self, db: None) -> None:
251
+ user = User.objects.create_user(
252
+ username="viewer", password="password", is_staff=True
253
+ )
254
+ content_type = ContentType.objects.get_for_model(TaskRecord)
255
+ view_perm = Permission.objects.get(
256
+ codename="view_taskrecord", content_type=content_type
257
+ )
258
+ user.user_permissions.add(view_perm)
259
+
260
+ model_admin = admin.site._registry[TaskRecord]
261
+ request = RequestFactory().get("/")
262
+ request.user = user
263
+
264
+ action_names = list(model_admin.get_actions(request).keys())
265
+ assert "replay_tasks" not in action_names
266
+
267
+ def test_replay_action_visible_with_change_permission(self, db: None) -> None:
268
+ user = User.objects.create_user(
269
+ username="editor", password="password", is_staff=True
270
+ )
271
+ content_type = ContentType.objects.get_for_model(TaskRecord)
272
+ change_perm = Permission.objects.get(
273
+ codename="change_taskrecord", content_type=content_type
274
+ )
275
+ user.user_permissions.add(change_perm)
276
+
277
+ model_admin = admin.site._registry[TaskRecord]
278
+ request = RequestFactory().get("/")
279
+ request.user = user
280
+
281
+ action_names = list(model_admin.get_actions(request).keys())
282
+ assert "replay_tasks" in action_names
@@ -344,7 +344,7 @@ class TestSubmitTask:
344
344
  assert str(parsed) == message_id
345
345
 
346
346
  def test_submit_task_does_not_wait_on_future(self, settings):
347
- """submit_task() does not call .result() or .add_done_callback() on the Future.
347
+ """submit_task() does not call .result() on the Future.
348
348
 
349
349
  Validates: Requirements 3.4, 5.3
350
350
  """
@@ -363,7 +363,24 @@ class TestSubmitTask:
363
363
  )
364
364
 
365
365
  mock_future.result.assert_not_called()
366
- mock_future.add_done_callback.assert_not_called()
366
+
367
+ def test_submit_task_attaches_exception_logging_callback(self, settings):
368
+ """submit_task() attaches _log_worker_exception as a done callback."""
369
+ from lambda_tasks.local_executor import _log_worker_exception, submit_task
370
+
371
+ settings.LAMBDA_TASKS_LOCAL_WORKERS = 2
372
+ settings.LAMBDA_TASKS_EAGER = False
373
+
374
+ mock_pool = MagicMock()
375
+ mock_future = MagicMock()
376
+ mock_pool.submit.return_value = mock_future
377
+
378
+ with patch("lambda_tasks.local_executor.get_pool", return_value=mock_pool):
379
+ submit_task(
380
+ message_json='{"task_name": "myapp.tasks.foo", "kwargs": {}, "n_retries": 0}'
381
+ )
382
+
383
+ mock_future.add_done_callback.assert_called_once_with(_log_worker_exception)
367
384
 
368
385
 
369
386
  # ---------------------------------------------------------------------------
@@ -1,21 +0,0 @@
1
- from django.contrib import admin
2
-
3
- import lambda_tasks.admin # noqa: F401 — triggers @admin.register side-effect
4
- from lambda_tasks.models import TaskRecord
5
-
6
-
7
- def test_task_record_registered_in_admin():
8
- assert TaskRecord in admin.site._registry
9
-
10
-
11
- def test_task_record_admin_list_display():
12
- assert admin.site._registry[TaskRecord].list_display == (
13
- "pk",
14
- "task_name",
15
- "status",
16
- "start_time",
17
- "end_time",
18
- "n_retries",
19
- "duration",
20
- "result",
21
- )