django-qstash 0.0.2__tar.gz → 0.0.3__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.
Potentially problematic release.
This version of django-qstash might be problematic. Click here for more details.
- {django_qstash-0.0.2 → django_qstash-0.0.3}/PKG-INFO +14 -10
- {django_qstash-0.0.2 → django_qstash-0.0.3}/README.md +13 -9
- {django_qstash-0.0.2 → django_qstash-0.0.3}/pyproject.toml +4 -1
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash/__init__.py +1 -1
- django_qstash-0.0.3/src/django_qstash/exceptions.py +25 -0
- django_qstash-0.0.3/src/django_qstash/handlers.py +143 -0
- django_qstash-0.0.3/src/django_qstash/management/commands/__init__.py +0 -0
- django_qstash-0.0.3/src/django_qstash/management/commands/clear_stale_results.py +61 -0
- django_qstash-0.0.3/src/django_qstash/results/__init__.py +0 -0
- django_qstash-0.0.3/src/django_qstash/results/admin.py +21 -0
- django_qstash-0.0.3/src/django_qstash/results/apps.py +10 -0
- django_qstash-0.0.3/src/django_qstash/results/migrations/0001_initial.py +62 -0
- django_qstash-0.0.3/src/django_qstash/results/migrations/__init__.py +0 -0
- django_qstash-0.0.3/src/django_qstash/results/models.py +36 -0
- django_qstash-0.0.3/src/django_qstash/results/services.py +33 -0
- django_qstash-0.0.3/src/django_qstash/views.py +21 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash.egg-info/PKG-INFO +14 -10
- django_qstash-0.0.3/src/django_qstash.egg-info/SOURCES.txt +28 -0
- django_qstash-0.0.3/tests/test_exceptions.py +56 -0
- django_qstash-0.0.3/tests/test_handlers.py +151 -0
- django_qstash-0.0.3/tests/test_results_models.py +76 -0
- django_qstash-0.0.3/tests/test_views.py +62 -0
- django_qstash-0.0.2/src/django_qstash/views.py +0 -124
- django_qstash-0.0.2/src/django_qstash.egg-info/SOURCES.txt +0 -14
- django_qstash-0.0.2/tests/test_views.py +0 -75
- {django_qstash-0.0.2 → django_qstash-0.0.3}/setup.cfg +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash/tasks.py +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash/utils.py +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash.egg-info/dependency_links.txt +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash.egg-info/requires.txt +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/src/django_qstash.egg-info/top_level.txt +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/tests/test_tasks.py +0 -0
- {django_qstash-0.0.2 → django_qstash-0.0.3}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: django-qstash
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
|
|
5
5
|
Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
|
|
6
6
|
Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
|
|
@@ -27,6 +27,8 @@ Requires-Dist: django>=4.2
|
|
|
27
27
|
Requires-Dist: qstash>=2
|
|
28
28
|
Requires-Dist: requests>=2.30
|
|
29
29
|
|
|
30
|
+
> :warning: **BETA Software**: Working on being production-ready soon.
|
|
31
|
+
|
|
30
32
|
# Django QStash `pip install django-qstash`
|
|
31
33
|
|
|
32
34
|
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
@@ -49,6 +51,7 @@ Depends on:
|
|
|
49
51
|
# from celery import shared_task
|
|
50
52
|
from django_qstash import shared_task
|
|
51
53
|
|
|
54
|
+
|
|
52
55
|
@shared_task
|
|
53
56
|
def math_add_task(a, b, save_to_file=False):
|
|
54
57
|
logger.info(f"Adding {a} and {b}")
|
|
@@ -73,23 +76,24 @@ math_add_task.delay(12, 454, save_to_file=True)
|
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
```python
|
|
76
|
-
QSTASH_TOKEN="your_token"
|
|
77
|
-
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
78
|
-
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
79
|
+
QSTASH_TOKEN = "your_token"
|
|
80
|
+
QSTASH_CURRENT_SIGNING_KEY = "your_current_signing_key"
|
|
81
|
+
QSTASH_NEXT_SIGNING_KEY = "your_next_signing_key"
|
|
79
82
|
|
|
80
83
|
# required for django-qstash
|
|
81
|
-
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
82
|
-
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
84
|
+
DJANGO_QSTASH_DOMAIN = "https://example.com"
|
|
85
|
+
DJANGO_QSTASH_WEBHOOK_PATH = "/qstash/webhook/"
|
|
83
86
|
```
|
|
84
87
|
|
|
85
88
|
|
|
89
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
88
92
|
|
|
89
|
-
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
90
93
|
|
|
94
|
+
`DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.
|
|
91
95
|
|
|
92
|
-
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
93
96
|
|
|
97
|
+
`DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.
|
|
94
98
|
|
|
95
|
-
`
|
|
99
|
+
`DJANGO_QSTASH_RESULT_TTL` (default:`604800`): A number of seconds after which task result data can be safely deleted. Defaults to 604800 seconds (7 days or 7 * 24 * 60 * 60).
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
> :warning: **BETA Software**: Working on being production-ready soon.
|
|
2
|
+
|
|
1
3
|
# Django QStash `pip install django-qstash`
|
|
2
4
|
|
|
3
5
|
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
@@ -20,6 +22,7 @@ Depends on:
|
|
|
20
22
|
# from celery import shared_task
|
|
21
23
|
from django_qstash import shared_task
|
|
22
24
|
|
|
25
|
+
|
|
23
26
|
@shared_task
|
|
24
27
|
def math_add_task(a, b, save_to_file=False):
|
|
25
28
|
logger.info(f"Adding {a} and {b}")
|
|
@@ -44,23 +47,24 @@ math_add_task.delay(12, 454, save_to_file=True)
|
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
```python
|
|
47
|
-
QSTASH_TOKEN="your_token"
|
|
48
|
-
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
49
|
-
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
50
|
+
QSTASH_TOKEN = "your_token"
|
|
51
|
+
QSTASH_CURRENT_SIGNING_KEY = "your_current_signing_key"
|
|
52
|
+
QSTASH_NEXT_SIGNING_KEY = "your_next_signing_key"
|
|
50
53
|
|
|
51
54
|
# required for django-qstash
|
|
52
|
-
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
53
|
-
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
55
|
+
DJANGO_QSTASH_DOMAIN = "https://example.com"
|
|
56
|
+
DJANGO_QSTASH_WEBHOOK_PATH = "/qstash/webhook/"
|
|
54
57
|
```
|
|
55
58
|
|
|
56
59
|
|
|
60
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
59
63
|
|
|
60
|
-
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
61
64
|
|
|
65
|
+
`DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.
|
|
62
66
|
|
|
63
|
-
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
64
67
|
|
|
68
|
+
`DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.
|
|
65
69
|
|
|
66
|
-
`
|
|
70
|
+
`DJANGO_QSTASH_RESULT_TTL` (default:`604800`): A number of seconds after which task result data can be safely deleted. Defaults to 604800 seconds (7 days or 7 * 24 * 60 * 60).
|
|
@@ -6,7 +6,7 @@ requires = [
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "django-qstash"
|
|
9
|
-
version = "0.0.
|
|
9
|
+
version = "0.0.3"
|
|
10
10
|
description = "A drop-in replacement for Celery's shared_task with Upstash QStash."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = { file = "LICENSE" }
|
|
@@ -72,6 +72,9 @@ source = [
|
|
|
72
72
|
"django_qstash",
|
|
73
73
|
"tests",
|
|
74
74
|
]
|
|
75
|
+
omit = [
|
|
76
|
+
"*/migrations/*",
|
|
77
|
+
]
|
|
75
78
|
|
|
76
79
|
[tool.coverage.paths]
|
|
77
80
|
source = [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WebhookError(Exception):
|
|
5
|
+
"""Base exception for webhook handling errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SignatureError(WebhookError):
|
|
11
|
+
"""Invalid or missing signature."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PayloadError(WebhookError):
|
|
17
|
+
"""Invalid payload structure or content."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TaskError(WebhookError):
|
|
23
|
+
"""Error in task execution."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.http import HttpRequest
|
|
10
|
+
from qstash import Receiver
|
|
11
|
+
|
|
12
|
+
from . import utils
|
|
13
|
+
from .exceptions import PayloadError
|
|
14
|
+
from .exceptions import SignatureError
|
|
15
|
+
from .exceptions import TaskError
|
|
16
|
+
from .results.services import store_task_result
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TaskPayload:
|
|
23
|
+
function: str
|
|
24
|
+
module: str
|
|
25
|
+
args: list
|
|
26
|
+
kwargs: dict
|
|
27
|
+
task_name: str
|
|
28
|
+
function_path: str
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: dict) -> TaskPayload:
|
|
32
|
+
"""Create TaskPayload from dictionary."""
|
|
33
|
+
is_valid, error = utils.validate_task_payload(data)
|
|
34
|
+
if not is_valid:
|
|
35
|
+
raise PayloadError(error)
|
|
36
|
+
|
|
37
|
+
function_path = f"{data['module']}.{data['function']}"
|
|
38
|
+
return cls(
|
|
39
|
+
function=data["function"],
|
|
40
|
+
module=data["module"],
|
|
41
|
+
args=data["args"],
|
|
42
|
+
kwargs=data["kwargs"],
|
|
43
|
+
task_name=data.get("task_name", function_path),
|
|
44
|
+
function_path=function_path,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QStashWebhook:
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.receiver = Receiver(
|
|
51
|
+
current_signing_key=settings.QSTASH_CURRENT_SIGNING_KEY,
|
|
52
|
+
next_signing_key=settings.QSTASH_NEXT_SIGNING_KEY,
|
|
53
|
+
)
|
|
54
|
+
self.force_https = getattr(settings, "DJANGO_QSTASH_FORCE_HTTPS", True)
|
|
55
|
+
|
|
56
|
+
def verify_signature(self, body: str, signature: str, url: str) -> None:
|
|
57
|
+
"""Verify QStash signature."""
|
|
58
|
+
if not signature:
|
|
59
|
+
raise SignatureError("Missing Upstash-Signature header")
|
|
60
|
+
|
|
61
|
+
if self.force_https and not url.startswith("https://"):
|
|
62
|
+
url = url.replace("http://", "https://")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
self.receiver.verify(body=body, signature=signature, url=url)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
raise SignatureError(f"Invalid signature: {e}")
|
|
68
|
+
|
|
69
|
+
def parse_payload(self, body: str) -> TaskPayload:
|
|
70
|
+
"""Parse and validate webhook payload."""
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(body)
|
|
73
|
+
return TaskPayload.from_dict(data)
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
raise PayloadError(f"Invalid JSON payload: {e}")
|
|
76
|
+
|
|
77
|
+
def execute_task(self, payload: TaskPayload) -> Any:
|
|
78
|
+
"""Import and execute the task function."""
|
|
79
|
+
try:
|
|
80
|
+
task_func = utils.import_string(payload.function_path)
|
|
81
|
+
except ImportError as e:
|
|
82
|
+
raise TaskError(f"Could not import task function: {e}")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
if callable(task_func) and hasattr(task_func, "actual_func"):
|
|
86
|
+
return task_func.actual_func(*payload.args, **payload.kwargs)
|
|
87
|
+
return task_func(*payload.args, **payload.kwargs)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise TaskError(f"Task execution failed: {e}")
|
|
90
|
+
|
|
91
|
+
def handle_request(self, request: HttpRequest) -> tuple[dict, int]:
|
|
92
|
+
"""Process webhook request and return response data and status code."""
|
|
93
|
+
payload = None # Initialize payload as None
|
|
94
|
+
try:
|
|
95
|
+
body = request.body.decode()
|
|
96
|
+
self.verify_signature(
|
|
97
|
+
body=body,
|
|
98
|
+
signature=request.headers.get("Upstash-Signature"),
|
|
99
|
+
url=request.build_absolute_uri(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
payload = self.parse_payload(body)
|
|
103
|
+
result = self.execute_task(payload)
|
|
104
|
+
|
|
105
|
+
store_task_result(
|
|
106
|
+
task_id=request.headers.get("Upstash-Message-Id"),
|
|
107
|
+
task_name=payload.task_name,
|
|
108
|
+
status="SUCCESS",
|
|
109
|
+
result=result,
|
|
110
|
+
args=payload.args,
|
|
111
|
+
kwargs=payload.kwargs,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"status": "success",
|
|
116
|
+
"task_name": payload.task_name,
|
|
117
|
+
"result": result if result is not None else "null",
|
|
118
|
+
}, 200
|
|
119
|
+
|
|
120
|
+
except (SignatureError, PayloadError) as e:
|
|
121
|
+
logger.exception("Authentication error: %s", str(e))
|
|
122
|
+
return {
|
|
123
|
+
"status": "error",
|
|
124
|
+
"error_type": e.__class__.__name__,
|
|
125
|
+
"error": str(e),
|
|
126
|
+
"task_name": getattr(payload, "task_name", None),
|
|
127
|
+
}, 400
|
|
128
|
+
except TaskError as e:
|
|
129
|
+
logger.exception("Task execution error: %s", str(e))
|
|
130
|
+
return {
|
|
131
|
+
"status": "error",
|
|
132
|
+
"error_type": e.__class__.__name__,
|
|
133
|
+
"error": str(e),
|
|
134
|
+
"task_name": payload.task_name if payload else None,
|
|
135
|
+
}, 500
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.exception("Unexpected error in webhook handler: %s", str(e))
|
|
138
|
+
return {
|
|
139
|
+
"status": "error",
|
|
140
|
+
"error_type": "InternalServerError",
|
|
141
|
+
"error": "An unexpected error occurred",
|
|
142
|
+
"task_name": getattr(payload, "task_name", None),
|
|
143
|
+
}, 500
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.core.management.base import BaseCommand
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
DJANGO_QSTASH_RESULT_TTL = getattr(settings, "DJANGO_QSTASH_RESULT_TTL", 604800)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseCommand):
|
|
14
|
+
help = f"""Clears stale task results older than\n
|
|
15
|
+
{DJANGO_QSTASH_RESULT_TTL} seconds (settings.DJANGO_QSTASH_RESULT_TTL)"""
|
|
16
|
+
|
|
17
|
+
def add_arguments(self, parser):
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--no-input",
|
|
20
|
+
action="store_true",
|
|
21
|
+
help="Do not ask for confirmation",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--since",
|
|
25
|
+
type=int,
|
|
26
|
+
help="The number of seconds ago to clear results for",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def handle(self, *args, **options):
|
|
30
|
+
no_input = options["no_input"]
|
|
31
|
+
since = options.get("since") or DJANGO_QSTASH_RESULT_TTL
|
|
32
|
+
cutoff_date = timezone.now() - timedelta(seconds=since)
|
|
33
|
+
try:
|
|
34
|
+
TaskResult = apps.get_model("django_qstash_results", "TaskResult")
|
|
35
|
+
except LookupError:
|
|
36
|
+
self.stdout.write(
|
|
37
|
+
self.style.ERROR(
|
|
38
|
+
"Django QStash Results not installed.\nAdd `django_qstash.results` to INSTALLED_APPS and run migrations."
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
to_delete = TaskResult.objects.filter(date_done__lt=cutoff_date)
|
|
43
|
+
|
|
44
|
+
if not to_delete.exists():
|
|
45
|
+
self.stdout.write("No stale Django QStash task results found")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# use input to confirm deletion
|
|
49
|
+
self.stdout.write(
|
|
50
|
+
f"Deleting {to_delete.count()} task results older than {cutoff_date} ({DJANGO_QSTASH_RESULT_TTL} seconds)"
|
|
51
|
+
)
|
|
52
|
+
if not no_input:
|
|
53
|
+
if input("Are you sure? (y/n): ") != "y":
|
|
54
|
+
self.stdout.write("Skipping deletion")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
deleted_count, _ = to_delete.delete()
|
|
58
|
+
|
|
59
|
+
self.stdout.write(
|
|
60
|
+
self.style.SUCCESS(f"Successfully deleted {deleted_count} stale results.")
|
|
61
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
|
|
5
|
+
from .models import TaskResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@admin.register(TaskResult)
|
|
9
|
+
class TaskResultAdmin(admin.ModelAdmin):
|
|
10
|
+
readonly_fields = [
|
|
11
|
+
"task_name",
|
|
12
|
+
"status",
|
|
13
|
+
"date_done",
|
|
14
|
+
"result",
|
|
15
|
+
"traceback",
|
|
16
|
+
"args",
|
|
17
|
+
"kwargs",
|
|
18
|
+
"task_id",
|
|
19
|
+
"date_created",
|
|
20
|
+
]
|
|
21
|
+
list_display = ["task_name", "status", "date_done"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResultsConfig(AppConfig):
|
|
7
|
+
name = "django_qstash.results"
|
|
8
|
+
label = "django_qstash_results"
|
|
9
|
+
verbose_name = "django_qstash_results"
|
|
10
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Generated by Django 5.1.4 on 2024-12-30 23:31
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
import django.utils.timezone
|
|
8
|
+
from django.db import migrations
|
|
9
|
+
from django.db import models
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Migration(migrations.Migration):
|
|
13
|
+
initial = True
|
|
14
|
+
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name="TaskResult",
|
|
20
|
+
fields=[
|
|
21
|
+
(
|
|
22
|
+
"id",
|
|
23
|
+
models.UUIDField(
|
|
24
|
+
default=uuid.uuid1,
|
|
25
|
+
editable=False,
|
|
26
|
+
primary_key=True,
|
|
27
|
+
serialize=False,
|
|
28
|
+
unique=True,
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"task_id",
|
|
33
|
+
models.CharField(db_index=True, max_length=255, unique=True),
|
|
34
|
+
),
|
|
35
|
+
("task_name", models.CharField(max_length=255)),
|
|
36
|
+
(
|
|
37
|
+
"status",
|
|
38
|
+
models.CharField(
|
|
39
|
+
choices=[
|
|
40
|
+
("PENDING", "Pending"),
|
|
41
|
+
("SUCCESS", "Success"),
|
|
42
|
+
("FAILURE", "Failure"),
|
|
43
|
+
],
|
|
44
|
+
default="PENDING",
|
|
45
|
+
max_length=50,
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"date_created",
|
|
50
|
+
models.DateTimeField(default=django.utils.timezone.now),
|
|
51
|
+
),
|
|
52
|
+
("date_done", models.DateTimeField(null=True)),
|
|
53
|
+
("result", models.JSONField(null=True)),
|
|
54
|
+
("traceback", models.TextField(null=True)),
|
|
55
|
+
("args", models.JSONField(null=True)),
|
|
56
|
+
("kwargs", models.JSONField(null=True)),
|
|
57
|
+
],
|
|
58
|
+
options={
|
|
59
|
+
"ordering": ["-date_done"],
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskResult(models.Model):
|
|
10
|
+
id = models.UUIDField(
|
|
11
|
+
primary_key=True, default=uuid.uuid1, editable=False, unique=True
|
|
12
|
+
)
|
|
13
|
+
task_id = models.CharField(max_length=255, unique=True, db_index=True)
|
|
14
|
+
task_name = models.CharField(max_length=255)
|
|
15
|
+
status = models.CharField(
|
|
16
|
+
max_length=50,
|
|
17
|
+
choices=[
|
|
18
|
+
("PENDING", "Pending"),
|
|
19
|
+
("SUCCESS", "Success"),
|
|
20
|
+
("FAILURE", "Failure"),
|
|
21
|
+
],
|
|
22
|
+
default="PENDING",
|
|
23
|
+
)
|
|
24
|
+
date_created = models.DateTimeField(default=timezone.now)
|
|
25
|
+
date_done = models.DateTimeField(null=True)
|
|
26
|
+
result = models.JSONField(null=True)
|
|
27
|
+
traceback = models.TextField(null=True)
|
|
28
|
+
args = models.JSONField(null=True)
|
|
29
|
+
kwargs = models.JSONField(null=True)
|
|
30
|
+
|
|
31
|
+
class Meta:
|
|
32
|
+
app_label = "django_qstash_results"
|
|
33
|
+
ordering = ["-date_done"]
|
|
34
|
+
|
|
35
|
+
def __str__(self):
|
|
36
|
+
return f"{self.task_name} ({self.task_id})"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def store_task_result(
|
|
12
|
+
task_id, task_name, status, result=None, traceback=None, args=None, kwargs=None
|
|
13
|
+
):
|
|
14
|
+
"""Helper function to store task results if the results app is installed"""
|
|
15
|
+
try:
|
|
16
|
+
TaskResult = apps.get_model("django_qstash_results", "TaskResult")
|
|
17
|
+
task_result = TaskResult.objects.create(
|
|
18
|
+
task_id=task_id,
|
|
19
|
+
task_name=task_name,
|
|
20
|
+
status=status,
|
|
21
|
+
date_done=timezone.now(),
|
|
22
|
+
result=result,
|
|
23
|
+
traceback=traceback,
|
|
24
|
+
args=args,
|
|
25
|
+
kwargs=kwargs,
|
|
26
|
+
)
|
|
27
|
+
return task_result
|
|
28
|
+
except LookupError:
|
|
29
|
+
# Model isn't installed, skip storage
|
|
30
|
+
logger.debug(
|
|
31
|
+
"Django QStash Results not installed. Add `django_qstash.results` to INSTALLED_APPS and run migrations."
|
|
32
|
+
)
|
|
33
|
+
return None
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from django.http import HttpRequest
|
|
6
|
+
from django.http import HttpResponse
|
|
7
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
8
|
+
from django.views.decorators.http import require_http_methods
|
|
9
|
+
|
|
10
|
+
from .handlers import QStashWebhook
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@csrf_exempt
|
|
14
|
+
@require_http_methods(["POST"])
|
|
15
|
+
def qstash_webhook_view(request: HttpRequest) -> HttpResponse:
|
|
16
|
+
"""Handle QStash webhook requests."""
|
|
17
|
+
webhook = QStashWebhook()
|
|
18
|
+
response_data, status_code = webhook.handle_request(request)
|
|
19
|
+
return HttpResponse(
|
|
20
|
+
json.dumps(response_data), status=status_code, content_type="application/json"
|
|
21
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: django-qstash
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: A drop-in replacement for Celery's shared_task with Upstash QStash.
|
|
5
5
|
Author-email: Justin Mitchel <justin@codingforentrepreneurs.com>
|
|
6
6
|
Project-URL: Changelog, https://github.com/jmitchel3/django-qstash
|
|
@@ -27,6 +27,8 @@ Requires-Dist: django>=4.2
|
|
|
27
27
|
Requires-Dist: qstash>=2
|
|
28
28
|
Requires-Dist: requests>=2.30
|
|
29
29
|
|
|
30
|
+
> :warning: **BETA Software**: Working on being production-ready soon.
|
|
31
|
+
|
|
30
32
|
# Django QStash `pip install django-qstash`
|
|
31
33
|
|
|
32
34
|
A drop-in replacement for Celery's shared_task leveraging Upstash QStash for a truly serverless Django application to run background tasks asynchronously from the request/response cycle.
|
|
@@ -49,6 +51,7 @@ Depends on:
|
|
|
49
51
|
# from celery import shared_task
|
|
50
52
|
from django_qstash import shared_task
|
|
51
53
|
|
|
54
|
+
|
|
52
55
|
@shared_task
|
|
53
56
|
def math_add_task(a, b, save_to_file=False):
|
|
54
57
|
logger.info(f"Adding {a} and {b}")
|
|
@@ -73,23 +76,24 @@ math_add_task.delay(12, 454, save_to_file=True)
|
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
```python
|
|
76
|
-
QSTASH_TOKEN="your_token"
|
|
77
|
-
QSTASH_CURRENT_SIGNING_KEY="your_current_signing_key"
|
|
78
|
-
QSTASH_NEXT_SIGNING_KEY="your_next_signing_key"
|
|
79
|
+
QSTASH_TOKEN = "your_token"
|
|
80
|
+
QSTASH_CURRENT_SIGNING_KEY = "your_current_signing_key"
|
|
81
|
+
QSTASH_NEXT_SIGNING_KEY = "your_next_signing_key"
|
|
79
82
|
|
|
80
83
|
# required for django-qstash
|
|
81
|
-
DJANGO_QSTASH_DOMAIN="https://example.com"
|
|
82
|
-
DJANGO_QSTASH_WEBHOOK_PATH="/qstash/webhook/"
|
|
84
|
+
DJANGO_QSTASH_DOMAIN = "https://example.com"
|
|
85
|
+
DJANGO_QSTASH_WEBHOOK_PATH = "/qstash/webhook/"
|
|
83
86
|
```
|
|
84
87
|
|
|
85
88
|
|
|
89
|
+
`DJANGO_QSTASH_DOMAIN`: Must be a valid and publicly accessible domain. For example `https://djangoqstash.com`
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
88
92
|
|
|
89
|
-
In development mode, we recommend using a tunnel like [Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) with a domain name you control. You can also consider [ngrok](https://ngrok.com/).
|
|
90
93
|
|
|
94
|
+
`DJANGO_QSTASH_WEBHOOK_PATH` (default:`/qstash/webhook/`): The path where QStash will send webhooks to your Django application.
|
|
91
95
|
|
|
92
|
-
`DJANGO_QSTASH_WEBHOOK_PATH`: The path where QStash will send webhooks to your Django application. Defaults to `/qstash/webhook/`
|
|
93
96
|
|
|
97
|
+
`DJANGO_QSTASH_FORCE_HTTPS` (default:`True`): Whether to force HTTPS for the webhook.
|
|
94
98
|
|
|
95
|
-
`
|
|
99
|
+
`DJANGO_QSTASH_RESULT_TTL` (default:`604800`): A number of seconds after which task result data can be safely deleted. Defaults to 604800 seconds (7 days or 7 * 24 * 60 * 60).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/django_qstash/__init__.py
|
|
4
|
+
src/django_qstash/exceptions.py
|
|
5
|
+
src/django_qstash/handlers.py
|
|
6
|
+
src/django_qstash/tasks.py
|
|
7
|
+
src/django_qstash/utils.py
|
|
8
|
+
src/django_qstash/views.py
|
|
9
|
+
src/django_qstash.egg-info/PKG-INFO
|
|
10
|
+
src/django_qstash.egg-info/SOURCES.txt
|
|
11
|
+
src/django_qstash.egg-info/dependency_links.txt
|
|
12
|
+
src/django_qstash.egg-info/requires.txt
|
|
13
|
+
src/django_qstash.egg-info/top_level.txt
|
|
14
|
+
src/django_qstash/management/commands/__init__.py
|
|
15
|
+
src/django_qstash/management/commands/clear_stale_results.py
|
|
16
|
+
src/django_qstash/results/__init__.py
|
|
17
|
+
src/django_qstash/results/admin.py
|
|
18
|
+
src/django_qstash/results/apps.py
|
|
19
|
+
src/django_qstash/results/models.py
|
|
20
|
+
src/django_qstash/results/services.py
|
|
21
|
+
src/django_qstash/results/migrations/0001_initial.py
|
|
22
|
+
src/django_qstash/results/migrations/__init__.py
|
|
23
|
+
tests/test_exceptions.py
|
|
24
|
+
tests/test_handlers.py
|
|
25
|
+
tests/test_results_models.py
|
|
26
|
+
tests/test_tasks.py
|
|
27
|
+
tests/test_utils.py
|
|
28
|
+
tests/test_views.py
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from django_qstash.exceptions import PayloadError
|
|
6
|
+
from django_qstash.exceptions import SignatureError
|
|
7
|
+
from django_qstash.exceptions import TaskError
|
|
8
|
+
from django_qstash.exceptions import WebhookError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_webhook_error():
|
|
12
|
+
"""Test that WebhookError can be raised and is an Exception"""
|
|
13
|
+
with pytest.raises(WebhookError):
|
|
14
|
+
raise WebhookError("Test webhook error")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_signature_error():
|
|
18
|
+
"""Test that SignatureError can be raised and is a WebhookError"""
|
|
19
|
+
with pytest.raises(SignatureError):
|
|
20
|
+
raise SignatureError("Invalid signature")
|
|
21
|
+
|
|
22
|
+
# Verify inheritance
|
|
23
|
+
assert issubclass(SignatureError, WebhookError)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_payload_error():
|
|
27
|
+
"""Test that PayloadError can be raised and is a WebhookError"""
|
|
28
|
+
with pytest.raises(PayloadError):
|
|
29
|
+
raise PayloadError("Invalid payload")
|
|
30
|
+
|
|
31
|
+
# Verify inheritance
|
|
32
|
+
assert issubclass(PayloadError, WebhookError)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_task_error():
|
|
36
|
+
"""Test that TaskError can be raised and is a WebhookError"""
|
|
37
|
+
with pytest.raises(TaskError):
|
|
38
|
+
raise TaskError("Task execution failed")
|
|
39
|
+
|
|
40
|
+
# Verify inheritance
|
|
41
|
+
assert issubclass(TaskError, WebhookError)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_error_messages():
|
|
45
|
+
"""Test that error messages are properly stored"""
|
|
46
|
+
message = "Custom error message"
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
raise WebhookError(message)
|
|
50
|
+
except WebhookError as e:
|
|
51
|
+
assert str(e) == message
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
raise SignatureError(message)
|
|
55
|
+
except SignatureError as e:
|
|
56
|
+
assert str(e) == message
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from django.http import HttpRequest
|
|
9
|
+
|
|
10
|
+
from django_qstash.exceptions import PayloadError
|
|
11
|
+
from django_qstash.exceptions import SignatureError
|
|
12
|
+
from django_qstash.exceptions import TaskError
|
|
13
|
+
from django_qstash.handlers import QStashWebhook
|
|
14
|
+
from django_qstash.handlers import TaskPayload
|
|
15
|
+
|
|
16
|
+
# Add pytest mark for database access
|
|
17
|
+
pytestmark = pytest.mark.django_db
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestTaskPayload:
|
|
21
|
+
def test_from_dict_valid(self):
|
|
22
|
+
data = {
|
|
23
|
+
"function": "test_func",
|
|
24
|
+
"module": "test_module",
|
|
25
|
+
"args": [1, 2],
|
|
26
|
+
"kwargs": {"key": "value"},
|
|
27
|
+
}
|
|
28
|
+
payload = TaskPayload.from_dict(data)
|
|
29
|
+
|
|
30
|
+
assert payload.function == "test_func"
|
|
31
|
+
assert payload.module == "test_module"
|
|
32
|
+
assert payload.args == [1, 2]
|
|
33
|
+
assert payload.kwargs == {"key": "value"}
|
|
34
|
+
assert payload.task_name == "test_module.test_func"
|
|
35
|
+
assert payload.function_path == "test_module.test_func"
|
|
36
|
+
|
|
37
|
+
def test_from_dict_invalid(self):
|
|
38
|
+
data = {"invalid": "data"}
|
|
39
|
+
with pytest.raises(PayloadError):
|
|
40
|
+
TaskPayload.from_dict(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestQStashWebhook:
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def webhook(self):
|
|
46
|
+
return QStashWebhook()
|
|
47
|
+
|
|
48
|
+
def test_verify_signature_missing(self, webhook):
|
|
49
|
+
with pytest.raises(SignatureError, match="Missing Upstash-Signature"):
|
|
50
|
+
webhook.verify_signature("body", "", "http://example.com")
|
|
51
|
+
|
|
52
|
+
def test_verify_signature_invalid(self, webhook):
|
|
53
|
+
with pytest.raises(SignatureError, match="Invalid signature"):
|
|
54
|
+
webhook.verify_signature("body", "invalid", "http://example.com")
|
|
55
|
+
|
|
56
|
+
def test_parse_payload_invalid_json(self, webhook):
|
|
57
|
+
with pytest.raises(PayloadError, match="Invalid JSON payload"):
|
|
58
|
+
webhook.parse_payload("invalid json")
|
|
59
|
+
|
|
60
|
+
def test_execute_task_import_error(self, webhook):
|
|
61
|
+
payload = Mock(function_path="nonexistent.module")
|
|
62
|
+
with pytest.raises(TaskError, match="Could not import task function"):
|
|
63
|
+
webhook.execute_task(payload)
|
|
64
|
+
|
|
65
|
+
def test_handle_request_success(self, webhook):
|
|
66
|
+
request = Mock(spec=HttpRequest)
|
|
67
|
+
request.body = json.dumps(
|
|
68
|
+
{"function": "test_func", "module": "test_module", "args": [], "kwargs": {}}
|
|
69
|
+
).encode()
|
|
70
|
+
request.headers = {"Upstash-Signature": "valid", "Upstash-Message-Id": "123"}
|
|
71
|
+
request.build_absolute_uri.return_value = "https://example.com"
|
|
72
|
+
|
|
73
|
+
with (
|
|
74
|
+
patch.object(webhook, "verify_signature"),
|
|
75
|
+
patch.object(webhook, "execute_task") as mock_execute,
|
|
76
|
+
):
|
|
77
|
+
mock_execute.return_value = "result"
|
|
78
|
+
response, status = webhook.handle_request(request)
|
|
79
|
+
|
|
80
|
+
assert status == 200
|
|
81
|
+
assert response["status"] == "success"
|
|
82
|
+
assert response["result"] == "result"
|
|
83
|
+
|
|
84
|
+
def test_handle_request_signature_error(self, webhook):
|
|
85
|
+
request = Mock(spec=HttpRequest)
|
|
86
|
+
request.body = json.dumps(
|
|
87
|
+
{"function": "test_func", "module": "test_module", "args": [], "kwargs": {}}
|
|
88
|
+
).encode()
|
|
89
|
+
request.headers = {}
|
|
90
|
+
request.build_absolute_uri.return_value = "https://example.com"
|
|
91
|
+
|
|
92
|
+
response, status = webhook.handle_request(request)
|
|
93
|
+
|
|
94
|
+
assert status == 400
|
|
95
|
+
assert response["status"] == "error"
|
|
96
|
+
assert response["error_type"] == "SignatureError"
|
|
97
|
+
|
|
98
|
+
def test_handle_request_task_error(self, webhook):
|
|
99
|
+
request = Mock(spec=HttpRequest)
|
|
100
|
+
request.body = json.dumps(
|
|
101
|
+
{"function": "test_func", "module": "test_module", "args": [], "kwargs": {}}
|
|
102
|
+
).encode()
|
|
103
|
+
request.headers = {"Upstash-Signature": "valid", "Upstash-Message-Id": "123"}
|
|
104
|
+
request.build_absolute_uri.return_value = "https://example.com"
|
|
105
|
+
|
|
106
|
+
with (
|
|
107
|
+
patch.object(webhook, "verify_signature"),
|
|
108
|
+
patch.object(webhook, "execute_task") as mock_execute,
|
|
109
|
+
):
|
|
110
|
+
mock_execute.side_effect = TaskError("Task failed")
|
|
111
|
+
response, status = webhook.handle_request(request)
|
|
112
|
+
|
|
113
|
+
assert status == 500
|
|
114
|
+
assert response["status"] == "error"
|
|
115
|
+
assert response["error_type"] == "TaskError"
|
|
116
|
+
assert response["error"] == "Task failed"
|
|
117
|
+
assert response["task_name"] is not None
|
|
118
|
+
|
|
119
|
+
def test_handle_request_unexpected_error(self, webhook):
|
|
120
|
+
request = Mock(spec=HttpRequest)
|
|
121
|
+
request.body = json.dumps(
|
|
122
|
+
{"function": "test_func", "module": "test_module", "args": [], "kwargs": {}}
|
|
123
|
+
).encode()
|
|
124
|
+
request.headers = {"Upstash-Signature": "valid"}
|
|
125
|
+
request.build_absolute_uri.return_value = "https://example.com"
|
|
126
|
+
|
|
127
|
+
with patch.object(webhook, "verify_signature") as mock_verify:
|
|
128
|
+
mock_verify.side_effect = Exception("Unexpected error")
|
|
129
|
+
response, status = webhook.handle_request(request)
|
|
130
|
+
|
|
131
|
+
assert status == 500
|
|
132
|
+
assert response["status"] == "error"
|
|
133
|
+
assert response["error_type"] == "InternalServerError"
|
|
134
|
+
assert response["error"] == "An unexpected error occurred"
|
|
135
|
+
assert response["task_name"] is None
|
|
136
|
+
|
|
137
|
+
def test_execute_task_with_actual_func(self, webhook):
|
|
138
|
+
def actual_function(*args, **kwargs):
|
|
139
|
+
return "actual result"
|
|
140
|
+
|
|
141
|
+
mock_func = Mock()
|
|
142
|
+
mock_func.actual_func = actual_function
|
|
143
|
+
|
|
144
|
+
payload = Mock(function_path="test.path", args=[1, 2], kwargs={"key": "value"})
|
|
145
|
+
|
|
146
|
+
with patch(
|
|
147
|
+
"django_qstash.handlers.utils.import_string", return_value=mock_func
|
|
148
|
+
):
|
|
149
|
+
result = webhook.execute_task(payload)
|
|
150
|
+
|
|
151
|
+
assert result == "actual result"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
|
|
9
|
+
from django_qstash.results.models import TaskResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.django_db(transaction=True)
|
|
13
|
+
class TestTaskResult:
|
|
14
|
+
def test_create_task_result(self):
|
|
15
|
+
task_result = TaskResult.objects.create(
|
|
16
|
+
task_id="test-task-123",
|
|
17
|
+
task_name="test_module.test_function",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
assert isinstance(task_result.id, uuid.UUID)
|
|
21
|
+
assert task_result.task_id == "test-task-123"
|
|
22
|
+
assert task_result.task_name == "test_module.test_function"
|
|
23
|
+
assert task_result.status == "PENDING"
|
|
24
|
+
assert isinstance(task_result.date_created, datetime)
|
|
25
|
+
assert task_result.date_done is None
|
|
26
|
+
assert task_result.result is None
|
|
27
|
+
assert task_result.traceback is None
|
|
28
|
+
assert task_result.args is None
|
|
29
|
+
assert task_result.kwargs is None
|
|
30
|
+
|
|
31
|
+
def test_str_representation(self):
|
|
32
|
+
task_result = TaskResult.objects.create(
|
|
33
|
+
task_id="test-task-123",
|
|
34
|
+
task_name="test_module.test_function",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert str(task_result) == "test_module.test_function (test-task-123)"
|
|
38
|
+
|
|
39
|
+
def test_task_result_with_all_fields(self):
|
|
40
|
+
task_result = TaskResult.objects.create(
|
|
41
|
+
task_id="test-task-456",
|
|
42
|
+
task_name="test_module.other_function",
|
|
43
|
+
status="SUCCESS",
|
|
44
|
+
date_done=timezone.now(),
|
|
45
|
+
result={"success": True},
|
|
46
|
+
traceback="No errors",
|
|
47
|
+
args=[1, 2, 3],
|
|
48
|
+
kwargs={"key": "value"},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert task_result.status == "SUCCESS"
|
|
52
|
+
assert isinstance(task_result.date_done, datetime)
|
|
53
|
+
assert task_result.result == {"success": True}
|
|
54
|
+
assert task_result.traceback == "No errors"
|
|
55
|
+
assert task_result.args == [1, 2, 3]
|
|
56
|
+
assert task_result.kwargs == {"key": "value"}
|
|
57
|
+
|
|
58
|
+
def test_ordering(self):
|
|
59
|
+
# Create tasks with different completion dates
|
|
60
|
+
older_task = TaskResult.objects.create(
|
|
61
|
+
task_id="older-task",
|
|
62
|
+
task_name="test.older",
|
|
63
|
+
status="SUCCESS",
|
|
64
|
+
date_done=timezone.now(),
|
|
65
|
+
)
|
|
66
|
+
newer_task = TaskResult.objects.create(
|
|
67
|
+
task_id="newer-task",
|
|
68
|
+
task_name="test.newer",
|
|
69
|
+
status="SUCCESS",
|
|
70
|
+
date_done=timezone.now(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Get all tasks and verify ordering
|
|
74
|
+
tasks = TaskResult.objects.all()
|
|
75
|
+
assert tasks[0].task_id == newer_task.task_id
|
|
76
|
+
assert tasks[1].task_id == older_task.task_id
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from django.test import Client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.django_db
|
|
11
|
+
class TestQStashWebhook:
|
|
12
|
+
def setup_method(self):
|
|
13
|
+
self.client = Client()
|
|
14
|
+
self.url = "/qstash/webhook/"
|
|
15
|
+
|
|
16
|
+
@patch("django_qstash.views.QStashWebhook")
|
|
17
|
+
def test_valid_webhook_request(self, mock_webhook_class):
|
|
18
|
+
"""Test webhook with valid signature and payload"""
|
|
19
|
+
# Setup mock webhook instance
|
|
20
|
+
mock_webhook = mock_webhook_class.return_value
|
|
21
|
+
mock_webhook.handle_request.return_value = ({"status": "success"}, 200)
|
|
22
|
+
|
|
23
|
+
payload = {
|
|
24
|
+
"function": "sample_task",
|
|
25
|
+
"module": "tests.test_tasks",
|
|
26
|
+
"args": [2, 3],
|
|
27
|
+
"kwargs": {},
|
|
28
|
+
"task_name": "test_task",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
response = self.client.post(
|
|
32
|
+
self.url,
|
|
33
|
+
data=json.dumps(payload),
|
|
34
|
+
content_type="application/json",
|
|
35
|
+
headers={"upstash-signature": "mock-signature"},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Verify response
|
|
39
|
+
assert response.status_code == 200
|
|
40
|
+
response_data = json.loads(response.content)
|
|
41
|
+
assert response_data["status"] == "success"
|
|
42
|
+
|
|
43
|
+
# Verify webhook was called correctly
|
|
44
|
+
mock_webhook.handle_request.assert_called_once()
|
|
45
|
+
|
|
46
|
+
@patch("django_qstash.views.QStashWebhook")
|
|
47
|
+
def test_invalid_request(self, mock_webhook_class):
|
|
48
|
+
"""Test webhook with invalid request"""
|
|
49
|
+
# Setup mock webhook instance
|
|
50
|
+
mock_webhook = mock_webhook_class.return_value
|
|
51
|
+
mock_webhook.handle_request.return_value = ({"error": "Invalid request"}, 400)
|
|
52
|
+
|
|
53
|
+
response = self.client.post(
|
|
54
|
+
self.url,
|
|
55
|
+
data="invalid json",
|
|
56
|
+
content_type="application/json",
|
|
57
|
+
headers={"upstash-signature": "mock-signature"},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
assert response.status_code == 400
|
|
61
|
+
response_data = json.loads(response.content)
|
|
62
|
+
assert "error" in response_data
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import logging
|
|
3
|
-
|
|
4
|
-
from django.conf import settings
|
|
5
|
-
from django.http import (
|
|
6
|
-
HttpRequest,
|
|
7
|
-
HttpResponse,
|
|
8
|
-
HttpResponseBadRequest,
|
|
9
|
-
HttpResponseForbidden,
|
|
10
|
-
)
|
|
11
|
-
from django.views.decorators.csrf import csrf_exempt
|
|
12
|
-
from django.views.decorators.http import require_http_methods
|
|
13
|
-
from qstash import Receiver
|
|
14
|
-
|
|
15
|
-
from . import utils
|
|
16
|
-
|
|
17
|
-
DJANGO_QSTASH_FORCE_HTTPS = getattr(settings, "DJANGO_QSTASH_FORCE_HTTPS", True)
|
|
18
|
-
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Initialize the QStash receiver
|
|
23
|
-
receiver = Receiver(
|
|
24
|
-
current_signing_key=settings.QSTASH_CURRENT_SIGNING_KEY,
|
|
25
|
-
next_signing_key=settings.QSTASH_NEXT_SIGNING_KEY,
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@csrf_exempt
|
|
30
|
-
@require_http_methods(["POST"])
|
|
31
|
-
def qstash_webhook_view(request: HttpRequest) -> HttpResponse:
|
|
32
|
-
"""
|
|
33
|
-
Webhook handler for QStash callbacks.
|
|
34
|
-
|
|
35
|
-
Expects a POST request with:
|
|
36
|
-
- Upstash-Signature header for verification
|
|
37
|
-
- JSON body containing task information:
|
|
38
|
-
{
|
|
39
|
-
"function": "full.path.to.function",
|
|
40
|
-
"module": "module.path",
|
|
41
|
-
"args": [...],
|
|
42
|
-
"kwargs": {...},
|
|
43
|
-
"task_name": "optional_task_name",
|
|
44
|
-
"options": {...}
|
|
45
|
-
}
|
|
46
|
-
"""
|
|
47
|
-
try:
|
|
48
|
-
# Get the signature from headers
|
|
49
|
-
signature = request.headers.get("Upstash-Signature")
|
|
50
|
-
if not signature:
|
|
51
|
-
return HttpResponseForbidden("Missing Upstash-Signature header")
|
|
52
|
-
|
|
53
|
-
# Verify the signature using the QStash SDK
|
|
54
|
-
url = request.build_absolute_uri()
|
|
55
|
-
if DJANGO_QSTASH_FORCE_HTTPS and not url.startswith("https://"):
|
|
56
|
-
url = url.replace("http://", "https://")
|
|
57
|
-
try:
|
|
58
|
-
receiver.verify(
|
|
59
|
-
body=request.body.decode(),
|
|
60
|
-
signature=signature,
|
|
61
|
-
url=url,
|
|
62
|
-
)
|
|
63
|
-
except Exception as e:
|
|
64
|
-
logger.error(f"Signature verification failed: {e}")
|
|
65
|
-
return HttpResponseForbidden("Invalid signature")
|
|
66
|
-
|
|
67
|
-
# Parse the payload
|
|
68
|
-
try:
|
|
69
|
-
payload = json.loads(request.body.decode())
|
|
70
|
-
except json.JSONDecodeError as e:
|
|
71
|
-
logger.error(f"Failed to parse JSON payload: {e}")
|
|
72
|
-
return HttpResponseBadRequest("Invalid JSON payload")
|
|
73
|
-
|
|
74
|
-
# Validate payload structure
|
|
75
|
-
is_valid, error_message = utils.validate_task_payload(payload)
|
|
76
|
-
if not is_valid:
|
|
77
|
-
logger.error(f"Invalid payload structure: {error_message}")
|
|
78
|
-
return HttpResponseBadRequest(error_message)
|
|
79
|
-
|
|
80
|
-
# Import the function
|
|
81
|
-
try:
|
|
82
|
-
function_path = f"{payload['module']}.{payload['function']}"
|
|
83
|
-
task_func = utils.import_string(function_path)
|
|
84
|
-
except ImportError as e:
|
|
85
|
-
logger.error(f"Failed to import task function: {e}")
|
|
86
|
-
return HttpResponseBadRequest(f"Could not import task function: {e}")
|
|
87
|
-
|
|
88
|
-
# Execute the task
|
|
89
|
-
try:
|
|
90
|
-
if hasattr(task_func, "__call__") and hasattr(task_func, "actual_func"):
|
|
91
|
-
# If it's a wrapped function, call the actual function directly
|
|
92
|
-
result = task_func.actual_func(*payload["args"], **payload["kwargs"])
|
|
93
|
-
else:
|
|
94
|
-
result = task_func(*payload["args"], **payload["kwargs"])
|
|
95
|
-
|
|
96
|
-
# Prepare the response
|
|
97
|
-
response_data = {
|
|
98
|
-
"status": "success",
|
|
99
|
-
"task_name": payload.get("task_name", function_path),
|
|
100
|
-
"result": result if result is not None else "null",
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return HttpResponse(
|
|
104
|
-
json.dumps(response_data), content_type="application/json"
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
except Exception as e:
|
|
108
|
-
logger.exception(f"Task execution failed: {e}")
|
|
109
|
-
error_response = {
|
|
110
|
-
"status": "error",
|
|
111
|
-
"task_name": payload.get("task_name", function_path),
|
|
112
|
-
"error": str(e),
|
|
113
|
-
}
|
|
114
|
-
return HttpResponse(
|
|
115
|
-
json.dumps(error_response), status=500, content_type="application/json"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
except Exception as e:
|
|
119
|
-
logger.exception(f"Unexpected error in webhook handler: {e}")
|
|
120
|
-
return HttpResponse(
|
|
121
|
-
json.dumps({"status": "error", "error": "Internal server error"}),
|
|
122
|
-
status=500,
|
|
123
|
-
content_type="application/json",
|
|
124
|
-
)
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
README.md
|
|
2
|
-
pyproject.toml
|
|
3
|
-
src/django_qstash/__init__.py
|
|
4
|
-
src/django_qstash/tasks.py
|
|
5
|
-
src/django_qstash/utils.py
|
|
6
|
-
src/django_qstash/views.py
|
|
7
|
-
src/django_qstash.egg-info/PKG-INFO
|
|
8
|
-
src/django_qstash.egg-info/SOURCES.txt
|
|
9
|
-
src/django_qstash.egg-info/dependency_links.txt
|
|
10
|
-
src/django_qstash.egg-info/requires.txt
|
|
11
|
-
src/django_qstash.egg-info/top_level.txt
|
|
12
|
-
tests/test_tasks.py
|
|
13
|
-
tests/test_utils.py
|
|
14
|
-
tests/test_views.py
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from unittest.mock import patch
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
from django.test import Client
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@pytest.mark.django_db
|
|
9
|
-
class TestQStashWebhook:
|
|
10
|
-
def setup_method(self):
|
|
11
|
-
self.client = Client()
|
|
12
|
-
self.url = "/qstash/webhook/" # Adjust if your URL is different
|
|
13
|
-
|
|
14
|
-
@patch("django_qstash.views.receiver")
|
|
15
|
-
def test_valid_webhook_request(self, mock_receiver):
|
|
16
|
-
"""Test webhook with valid signature and payload"""
|
|
17
|
-
payload = {
|
|
18
|
-
"function": "sample_task",
|
|
19
|
-
"module": "tests.test_tasks",
|
|
20
|
-
"args": [2, 3],
|
|
21
|
-
"kwargs": {},
|
|
22
|
-
"task_name": "test_task",
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
# Mock signature verification
|
|
26
|
-
mock_receiver.verify.return_value = True
|
|
27
|
-
|
|
28
|
-
response = self.client.post(
|
|
29
|
-
self.url,
|
|
30
|
-
data=json.dumps(payload),
|
|
31
|
-
content_type="application/json",
|
|
32
|
-
headers={"upstash-signature": "mock-signature"},
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
assert response.status_code == 200
|
|
36
|
-
response_data = json.loads(response.content)
|
|
37
|
-
assert response_data["status"] == "success"
|
|
38
|
-
|
|
39
|
-
def test_missing_signature(self):
|
|
40
|
-
"""Test webhook request without signature header"""
|
|
41
|
-
response = self.client.post(
|
|
42
|
-
self.url, data=json.dumps({}), content_type="application/json"
|
|
43
|
-
)
|
|
44
|
-
assert response.status_code == 403
|
|
45
|
-
|
|
46
|
-
@patch("django_qstash.views.receiver")
|
|
47
|
-
def test_invalid_json_payload(self, mock_receiver):
|
|
48
|
-
"""Test webhook with invalid JSON payload"""
|
|
49
|
-
mock_receiver.verify.return_value = True
|
|
50
|
-
|
|
51
|
-
response = self.client.post(
|
|
52
|
-
self.url,
|
|
53
|
-
data="invalid json",
|
|
54
|
-
content_type="application/json",
|
|
55
|
-
headers={"upstash-signature": "mock-signature"},
|
|
56
|
-
)
|
|
57
|
-
assert response.status_code == 400
|
|
58
|
-
|
|
59
|
-
@patch("django_qstash.views.receiver")
|
|
60
|
-
def test_invalid_payload_structure(self, mock_receiver):
|
|
61
|
-
"""Test webhook with missing required fields"""
|
|
62
|
-
mock_receiver.verify.return_value = True
|
|
63
|
-
|
|
64
|
-
payload = {
|
|
65
|
-
"function": "sample_task",
|
|
66
|
-
# missing required fields
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
response = self.client.post(
|
|
70
|
-
self.url,
|
|
71
|
-
data=json.dumps(payload),
|
|
72
|
-
content_type="application/json",
|
|
73
|
-
headers={"upstash-signature": "mock-signature"},
|
|
74
|
-
)
|
|
75
|
-
assert response.status_code == 400
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|