django-qstash 0.0.5__tar.gz → 0.0.7__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.5 → django_qstash-0.0.7}/PKG-INFO +1 -1
- {django_qstash-0.0.5 → django_qstash-0.0.7}/pyproject.toml +3 -1
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/__init__.py +1 -1
- django_qstash-0.0.7/src/django_qstash/callbacks.py +15 -0
- django_qstash-0.0.7/src/django_qstash/client.py +7 -0
- django_qstash-0.0.7/src/django_qstash/discovery/fields.py +38 -0
- django_qstash-0.0.7/src/django_qstash/discovery/models.py +25 -0
- django_qstash-0.0.7/src/django_qstash/discovery/utils.py +93 -0
- django_qstash-0.0.7/src/django_qstash/discovery/validators.py +24 -0
- django_qstash-0.0.7/src/django_qstash/management/commands/task_schedules.py +116 -0
- django_qstash-0.0.7/src/django_qstash/results/__init__.py +0 -0
- django_qstash-0.0.7/src/django_qstash/results/migrations/__init__.py +0 -0
- django_qstash-0.0.7/src/django_qstash/schedules/__init__.py +0 -0
- django_qstash-0.0.7/src/django_qstash/schedules/admin.py +66 -0
- django_qstash-0.0.7/src/django_qstash/schedules/apps.py +13 -0
- django_qstash-0.0.7/src/django_qstash/schedules/exceptions.py +9 -0
- django_qstash-0.0.7/src/django_qstash/schedules/formatters.py +37 -0
- django_qstash-0.0.7/src/django_qstash/schedules/forms.py +24 -0
- django_qstash-0.0.7/src/django_qstash/schedules/migrations/0001_initial.py +122 -0
- django_qstash-0.0.7/src/django_qstash/schedules/migrations/0002_taskschedule_updated_at.py +20 -0
- django_qstash-0.0.7/src/django_qstash/schedules/migrations/__init__.py +0 -0
- django_qstash-0.0.7/src/django_qstash/schedules/models.py +120 -0
- django_qstash-0.0.7/src/django_qstash/schedules/services.py +64 -0
- django_qstash-0.0.7/src/django_qstash/schedules/signals.py +21 -0
- django_qstash-0.0.7/src/django_qstash/schedules/validators.py +29 -0
- django_qstash-0.0.7/src/django_qstash/settings.py +17 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/tasks.py +5 -25
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash.egg-info/PKG-INFO +1 -1
- django_qstash-0.0.7/src/django_qstash.egg-info/SOURCES.txt +53 -0
- django_qstash-0.0.7/tests/test_callbacks.py +62 -0
- django_qstash-0.0.7/tests/test_settings.py +63 -0
- django_qstash-0.0.5/src/django_qstash.egg-info/SOURCES.txt +0 -28
- {django_qstash-0.0.5 → django_qstash-0.0.7}/README.md +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/setup.cfg +0 -0
- {django_qstash-0.0.5/src/django_qstash/management/commands → django_qstash-0.0.7/src/django_qstash/discovery}/__init__.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/exceptions.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/handlers.py +0 -0
- {django_qstash-0.0.5/src/django_qstash/results → django_qstash-0.0.7/src/django_qstash/management}/__init__.py +0 -0
- {django_qstash-0.0.5/src/django_qstash/results/migrations → django_qstash-0.0.7/src/django_qstash/management/commands}/__init__.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/management/commands/clear_stale_results.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/results/admin.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/results/apps.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/results/migrations/0001_initial.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/results/models.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/results/services.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/utils.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash/views.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash.egg-info/dependency_links.txt +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash.egg-info/requires.txt +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/src/django_qstash.egg-info/top_level.txt +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/tests/test_exceptions.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/tests/test_handlers.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/tests/test_results_models.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/tests/test_tasks.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/tests/test_utils.py +0 -0
- {django_qstash-0.0.5 → django_qstash-0.0.7}/tests/test_views.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.7
|
|
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
|
|
@@ -6,7 +6,7 @@ requires = [
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "django-qstash"
|
|
9
|
-
version = "0.0.
|
|
9
|
+
version = "0.0.7"
|
|
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" }
|
|
@@ -74,6 +74,8 @@ source = [
|
|
|
74
74
|
]
|
|
75
75
|
omit = [
|
|
76
76
|
"*/migrations/*",
|
|
77
|
+
"*/admin.py",
|
|
78
|
+
"tests/*",
|
|
77
79
|
]
|
|
78
80
|
|
|
79
81
|
[tool.coverage.paths]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django_qstash.settings import DJANGO_QSTASH_DOMAIN
|
|
4
|
+
from django_qstash.settings import DJANGO_QSTASH_WEBHOOK_PATH
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_callback_url() -> str:
|
|
8
|
+
"""
|
|
9
|
+
Get the callback URL based on the settings.
|
|
10
|
+
"""
|
|
11
|
+
callback_domain = DJANGO_QSTASH_DOMAIN.rstrip("/")
|
|
12
|
+
if not callback_domain.startswith(("http://", "https://")):
|
|
13
|
+
callback_domain = f"https://{callback_domain}"
|
|
14
|
+
webhook_path = DJANGO_QSTASH_WEBHOOK_PATH.strip("/")
|
|
15
|
+
return f"{callback_domain}/{webhook_path}/"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django import forms
|
|
4
|
+
|
|
5
|
+
from django_qstash.discovery.utils import discover_tasks
|
|
6
|
+
from django_qstash.discovery.validators import task_exists_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskChoiceField(forms.ChoiceField):
|
|
10
|
+
"""
|
|
11
|
+
A form field that provides choices from discovered QStash tasks
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, **kwargs):
|
|
15
|
+
# Remove max_length if it's present since ChoiceField doesn't use it
|
|
16
|
+
kwargs.pop("max_length", None)
|
|
17
|
+
|
|
18
|
+
# Get tasks before calling parent to set choices
|
|
19
|
+
tasks = discover_tasks()
|
|
20
|
+
|
|
21
|
+
# Convert tasks to choices using (task_name, task_name) format
|
|
22
|
+
task_choices = [(task_value, task_label) for task_value, task_label in tasks]
|
|
23
|
+
|
|
24
|
+
kwargs["choices"] = task_choices
|
|
25
|
+
kwargs["validators"] = [task_exists_validator] + kwargs.get("validators", [])
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
def get_task(self):
|
|
29
|
+
"""
|
|
30
|
+
Returns the actual task dot notation path for the selected value
|
|
31
|
+
"""
|
|
32
|
+
if self.data:
|
|
33
|
+
tasks = discover_tasks()
|
|
34
|
+
|
|
35
|
+
for task_value, task_label in tasks:
|
|
36
|
+
if task_label == self.data:
|
|
37
|
+
return task_value
|
|
38
|
+
return None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
from django_qstash.discovery.fields import TaskChoiceField
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaskField(models.CharField):
|
|
9
|
+
"""
|
|
10
|
+
A model field for storing QStash task references
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *args, **kwargs):
|
|
14
|
+
# Set a reasonable max_length for task names if not provided
|
|
15
|
+
if "max_length" not in kwargs:
|
|
16
|
+
kwargs["max_length"] = 255
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
|
|
19
|
+
def formfield(self, **kwargs):
|
|
20
|
+
# Use our custom form field
|
|
21
|
+
defaults = {
|
|
22
|
+
"form_class": TaskChoiceField,
|
|
23
|
+
}
|
|
24
|
+
defaults.update(kwargs)
|
|
25
|
+
return super().formfield(**defaults)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import warnings
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
|
|
9
|
+
from django.apps import apps
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.signals import request_started
|
|
12
|
+
from django.utils.module_loading import module_has_submodule
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DJANGO_QSTASH_DISCOVER_INCLUDE_SETTINGS_DIR = getattr(
|
|
17
|
+
settings, "DJANGO_QSTASH_DISCOVER_INCLUDE_SETTINGS_DIR", True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=None)
|
|
22
|
+
def discover_tasks() -> list[tuple[str, str]]:
|
|
23
|
+
"""
|
|
24
|
+
Automatically discover tasks in Django apps and return them as a list of tuples.
|
|
25
|
+
Each tuple contains (dot_notation_path, task_name).
|
|
26
|
+
If no custom task name is specified, both values will be the dot notation path.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of tuples: [(dot_notation_path, task_name), ...]
|
|
30
|
+
Example: [
|
|
31
|
+
('example_app.tasks.my_task', 'example_app.tasks.my_task'),
|
|
32
|
+
('other_app.tasks.custom_task', 'special_name')
|
|
33
|
+
]
|
|
34
|
+
"""
|
|
35
|
+
from django_qstash.tasks import QStashTask
|
|
36
|
+
|
|
37
|
+
discovered_tasks = []
|
|
38
|
+
packages = []
|
|
39
|
+
|
|
40
|
+
# Add Django apps that contain tasks.py
|
|
41
|
+
for app_config in apps.get_app_configs():
|
|
42
|
+
if module_has_submodule(app_config.module, "tasks"):
|
|
43
|
+
packages.append(app_config.name)
|
|
44
|
+
|
|
45
|
+
# Add the directory containing settings.py if it has a tasks.py module
|
|
46
|
+
if DJANGO_QSTASH_DISCOVER_INCLUDE_SETTINGS_DIR:
|
|
47
|
+
settings_module = os.environ.get("DJANGO_SETTINGS_MODULE", "")
|
|
48
|
+
if settings_module:
|
|
49
|
+
settings_package = settings_module.rsplit(".", 1)[0]
|
|
50
|
+
try:
|
|
51
|
+
settings_module_obj = import_module(settings_package)
|
|
52
|
+
if module_has_submodule(settings_module_obj, "tasks"):
|
|
53
|
+
packages.append(settings_package)
|
|
54
|
+
except ImportError:
|
|
55
|
+
warnings.warn(
|
|
56
|
+
f"Could not import settings package {settings_package} for task discovery",
|
|
57
|
+
RuntimeWarning,
|
|
58
|
+
stacklevel=2,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Rest of the discovery logic
|
|
62
|
+
for package in packages:
|
|
63
|
+
try:
|
|
64
|
+
tasks_module = import_module(f"{package}.tasks")
|
|
65
|
+
# Find all attributes that are QstashTask instances
|
|
66
|
+
for attr_name in dir(tasks_module):
|
|
67
|
+
attr = getattr(tasks_module, attr_name)
|
|
68
|
+
|
|
69
|
+
if isinstance(attr, QStashTask):
|
|
70
|
+
value = f"{package}.tasks.{attr_name}"
|
|
71
|
+
if attr.name == attr_name:
|
|
72
|
+
label = value
|
|
73
|
+
else:
|
|
74
|
+
label = f"{attr.name} ({value})"
|
|
75
|
+
discovered_tasks.append((value, label))
|
|
76
|
+
except Exception as e:
|
|
77
|
+
warnings.warn(
|
|
78
|
+
f"Failed to import tasks from {package}: {str(e)}",
|
|
79
|
+
RuntimeWarning,
|
|
80
|
+
stacklevel=2,
|
|
81
|
+
)
|
|
82
|
+
return discovered_tasks
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear_discover_tasks_cache(sender, **kwargs):
|
|
86
|
+
logger.info("Clearing Django QStash discovered tasks cache")
|
|
87
|
+
discover_tasks.cache_clear()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
request_started.connect(
|
|
91
|
+
clear_discover_tasks_cache,
|
|
92
|
+
dispatch_uid="clear_django_qstash_discovered_tasks_cache",
|
|
93
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ValidationError
|
|
4
|
+
|
|
5
|
+
from django_qstash.discovery.utils import discover_tasks
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def task_exists_validator(task_name):
|
|
9
|
+
"""
|
|
10
|
+
Validates that a task name exists in the discovered tasks
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
task_name: The name of the task to validate
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
ValidationError: If the task cannot be found
|
|
17
|
+
"""
|
|
18
|
+
tasks = discover_tasks()
|
|
19
|
+
available_tasks = [task[0] for task in tasks]
|
|
20
|
+
|
|
21
|
+
if task_name not in available_tasks:
|
|
22
|
+
raise ValidationError(
|
|
23
|
+
f"Task '{task_name}' not found. Available tasks: {', '.join(available_tasks)}"
|
|
24
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from django.core.management.base import BaseCommand
|
|
8
|
+
from django.db import models
|
|
9
|
+
|
|
10
|
+
from django_qstash.callbacks import get_callback_url
|
|
11
|
+
from django_qstash.client import qstash_client
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Command(BaseCommand):
|
|
17
|
+
"""Management command to list and sync QStash schedules."""
|
|
18
|
+
|
|
19
|
+
help = "List and sync schedules from QStash"
|
|
20
|
+
|
|
21
|
+
def add_arguments(self, parser) -> None:
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--list",
|
|
24
|
+
action="store_true",
|
|
25
|
+
help="List schedules from QStash",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--sync",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Sync schedules from QStash to local database",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--no-input",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Do not ask for confirmation",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_task_schedule_model(self) -> models.Model | None:
|
|
39
|
+
"""Get the TaskSchedule model if available."""
|
|
40
|
+
try:
|
|
41
|
+
return apps.get_model("django_qstash_schedules", "TaskSchedule")
|
|
42
|
+
except LookupError:
|
|
43
|
+
self.stdout.write(
|
|
44
|
+
self.style.ERROR(
|
|
45
|
+
"Django QStash Schedules not installed.\n"
|
|
46
|
+
"Add `django_qstash.schedules` to INSTALLED_APPS and run migrations."
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def sync_schedules(self, schedules: list) -> None:
|
|
51
|
+
"""Sync remote schedules to local database."""
|
|
52
|
+
TaskSchedule = self.get_task_schedule_model()
|
|
53
|
+
|
|
54
|
+
for schedule in schedules:
|
|
55
|
+
try:
|
|
56
|
+
body = json.loads(schedule.body)
|
|
57
|
+
task_name = body.get("task_name", "Unnamed Task")
|
|
58
|
+
function = f"{body['module']}.{body['function']}"
|
|
59
|
+
|
|
60
|
+
obj, created = TaskSchedule.objects.update_or_create(
|
|
61
|
+
schedule_id=schedule.schedule_id,
|
|
62
|
+
defaults={
|
|
63
|
+
"name": task_name,
|
|
64
|
+
"task": function,
|
|
65
|
+
"cron": schedule.cron,
|
|
66
|
+
"args": body.get("args", []),
|
|
67
|
+
"kwargs": body.get("kwargs", {}),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
status = "Created" if created else "Updated"
|
|
71
|
+
logger.info(
|
|
72
|
+
"%s schedule: %s (%s)", status, task_name, schedule.schedule_id
|
|
73
|
+
)
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.exception("Failed to sync schedule %s", schedule.schedule_id)
|
|
76
|
+
|
|
77
|
+
def handle(self, *args, **options) -> None:
|
|
78
|
+
auto_confirm = options.get("no_input")
|
|
79
|
+
if not (options.get("sync") or options.get("list")):
|
|
80
|
+
self.stdout.write(
|
|
81
|
+
self.style.ERROR("Please specify either --list or --sync option")
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
destination = get_callback_url()
|
|
87
|
+
schedules = qstash_client.schedule.list()
|
|
88
|
+
|
|
89
|
+
self.stdout.write(
|
|
90
|
+
self.style.SUCCESS(
|
|
91
|
+
f"Found {len(schedules)} remote schedules based on destination: {destination}"
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
for schedule in schedules:
|
|
96
|
+
body = json.loads(schedule.body)
|
|
97
|
+
task_name = body.get("task_name", "Unnamed Task")
|
|
98
|
+
function = f"{body['module']}.{body['function']}"
|
|
99
|
+
|
|
100
|
+
self.stdout.write(
|
|
101
|
+
f"\nSchedule ID: {schedule.schedule_id}"
|
|
102
|
+
f"\n Task: {task_name} ({function})"
|
|
103
|
+
f"\n Cron: {schedule.cron}"
|
|
104
|
+
f"\n Destination: {schedule.destination}"
|
|
105
|
+
f"\n Retries: {schedule.retries}"
|
|
106
|
+
f"\n Status: {'Paused' if schedule.paused else 'Active'}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if options.get("sync"):
|
|
110
|
+
user_input = input("Do you want to sync remote schedules? (y/n): ")
|
|
111
|
+
if user_input.lower() == "y" or auto_confirm:
|
|
112
|
+
self.sync_schedules(schedules)
|
|
113
|
+
else:
|
|
114
|
+
self.stdout.write(self.style.ERROR("Sync cancelled"))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
|
|
5
|
+
from django_qstash.schedules import services
|
|
6
|
+
from django_qstash.schedules.forms import TaskScheduleForm
|
|
7
|
+
from django_qstash.schedules.models import TaskSchedule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@admin.register(TaskSchedule)
|
|
11
|
+
class TaskScheduleAdmin(admin.ModelAdmin):
|
|
12
|
+
list_display = ["schedule_id", "task_name"]
|
|
13
|
+
readonly_fields = [
|
|
14
|
+
"schedule_id",
|
|
15
|
+
"task_name",
|
|
16
|
+
"get_qstash_schedule_details",
|
|
17
|
+
"paused_at",
|
|
18
|
+
"resumed_at",
|
|
19
|
+
"active_at",
|
|
20
|
+
]
|
|
21
|
+
form = TaskScheduleForm
|
|
22
|
+
|
|
23
|
+
fieldsets = [
|
|
24
|
+
(
|
|
25
|
+
"Name",
|
|
26
|
+
{
|
|
27
|
+
"fields": ["name", "schedule_id", "is_active"],
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"Task Selection",
|
|
32
|
+
{
|
|
33
|
+
"fields": ["task", "task_name"],
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
(
|
|
37
|
+
"Arguments",
|
|
38
|
+
{
|
|
39
|
+
"fields": ["args", "kwargs"],
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"Schedule",
|
|
44
|
+
{
|
|
45
|
+
"fields": ["cron", "retries", "timeout"],
|
|
46
|
+
},
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"QStash Metadata",
|
|
50
|
+
{
|
|
51
|
+
"fields": [
|
|
52
|
+
"paused_at",
|
|
53
|
+
"resumed_at",
|
|
54
|
+
"active_at",
|
|
55
|
+
"get_qstash_schedule_details",
|
|
56
|
+
],
|
|
57
|
+
"classes": ["collapse"],
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
@admin.display(description="Raw")
|
|
63
|
+
def get_qstash_schedule_details(self, obj: TaskSchedule) -> dict:
|
|
64
|
+
if not obj.schedule_id:
|
|
65
|
+
return "No schedule ID yet"
|
|
66
|
+
return services.get_task_schedule_from_qstash(obj, as_dict=True)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SchedulesConfig(AppConfig):
|
|
7
|
+
name = "django_qstash.schedules"
|
|
8
|
+
label = "django_qstash_schedules"
|
|
9
|
+
verbose_name = "django_qstash_schedules"
|
|
10
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
11
|
+
|
|
12
|
+
def ready(self):
|
|
13
|
+
import django_qstash.schedules.signals # noqa
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django_qstash.callbacks import get_callback_url
|
|
7
|
+
from django_qstash.schedules.models import TaskSchedule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def prepare_qstash_payload(instance: TaskSchedule) -> dict[str, Any]:
|
|
11
|
+
"""Prepare the task payload for QStash"""
|
|
12
|
+
return {
|
|
13
|
+
"function": instance.task_name.split(".")[-1], # Get function name
|
|
14
|
+
"module": ".".join(instance.task_name.split(".")[:-1]), # Get module path
|
|
15
|
+
"args": instance.args,
|
|
16
|
+
"kwargs": instance.kwargs,
|
|
17
|
+
"task_name": instance.name,
|
|
18
|
+
"options": {
|
|
19
|
+
"max_retries": instance.retries,
|
|
20
|
+
"timeout": instance.timeout,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_task_schedule_for_qstash(instance: TaskSchedule) -> dict[str, Any]:
|
|
26
|
+
payload = prepare_qstash_payload(instance)
|
|
27
|
+
callback_url = get_callback_url()
|
|
28
|
+
data = {
|
|
29
|
+
"destination": callback_url,
|
|
30
|
+
"body": json.dumps(payload),
|
|
31
|
+
"cron": instance.cron,
|
|
32
|
+
"retries": instance.retries,
|
|
33
|
+
"timeout": instance.timeout,
|
|
34
|
+
}
|
|
35
|
+
if instance.schedule_id:
|
|
36
|
+
data["schedule_id"] = instance.schedule_id
|
|
37
|
+
return data
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django import forms
|
|
4
|
+
|
|
5
|
+
from django_qstash.discovery.fields import TaskChoiceField
|
|
6
|
+
from django_qstash.schedules.models import TaskSchedule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskScheduleForm(forms.ModelForm):
|
|
10
|
+
task = TaskChoiceField()
|
|
11
|
+
|
|
12
|
+
class Meta:
|
|
13
|
+
model = TaskSchedule
|
|
14
|
+
fields = [
|
|
15
|
+
"name",
|
|
16
|
+
"task",
|
|
17
|
+
"task_name",
|
|
18
|
+
"args",
|
|
19
|
+
"kwargs",
|
|
20
|
+
"schedule_id",
|
|
21
|
+
"cron",
|
|
22
|
+
"retries",
|
|
23
|
+
"timeout",
|
|
24
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Generated by Django 5.1.4 on 2025-01-02 06:28
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import django.core.validators
|
|
6
|
+
import django.utils.timezone
|
|
7
|
+
from django.db import migrations
|
|
8
|
+
from django.db import models
|
|
9
|
+
|
|
10
|
+
import django_qstash.discovery.models
|
|
11
|
+
import django_qstash.schedules.validators
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Migration(migrations.Migration):
|
|
15
|
+
initial = True
|
|
16
|
+
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
operations = [
|
|
20
|
+
migrations.CreateModel(
|
|
21
|
+
name="TaskSchedule",
|
|
22
|
+
fields=[
|
|
23
|
+
(
|
|
24
|
+
"id",
|
|
25
|
+
models.BigAutoField(
|
|
26
|
+
auto_created=True,
|
|
27
|
+
primary_key=True,
|
|
28
|
+
serialize=False,
|
|
29
|
+
verbose_name="ID",
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"schedule_id",
|
|
34
|
+
models.CharField(
|
|
35
|
+
blank=True,
|
|
36
|
+
db_index=True,
|
|
37
|
+
help_text="The schedule ID stored in QStash",
|
|
38
|
+
max_length=255,
|
|
39
|
+
null=True,
|
|
40
|
+
unique=True,
|
|
41
|
+
verbose_name="Schedule ID",
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"name",
|
|
46
|
+
models.CharField(
|
|
47
|
+
help_text="Short Description For This Task Schedule",
|
|
48
|
+
max_length=200,
|
|
49
|
+
verbose_name="Name",
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
("task", django_qstash.discovery.models.TaskField(max_length=255)),
|
|
53
|
+
(
|
|
54
|
+
"task_name",
|
|
55
|
+
models.CharField(
|
|
56
|
+
blank=True,
|
|
57
|
+
help_text="Original Python location of task",
|
|
58
|
+
max_length=255,
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
"args",
|
|
63
|
+
models.JSONField(
|
|
64
|
+
blank=True,
|
|
65
|
+
default=list,
|
|
66
|
+
help_text='JSON encoded positional arguments (Example: ["arg1", "arg2"])',
|
|
67
|
+
verbose_name="Positional Arguments",
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"kwargs",
|
|
72
|
+
models.JSONField(
|
|
73
|
+
blank=True,
|
|
74
|
+
default=dict,
|
|
75
|
+
help_text='JSON encoded keyword arguments (Example: {"argument": "value"})',
|
|
76
|
+
verbose_name="Keyword Arguments",
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"cron",
|
|
81
|
+
models.CharField(
|
|
82
|
+
default="*/5 * * * *",
|
|
83
|
+
help_text="Cron expression for scheduling the task",
|
|
84
|
+
max_length=255,
|
|
85
|
+
verbose_name="Cron Expression",
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"retries",
|
|
90
|
+
models.IntegerField(
|
|
91
|
+
default=3,
|
|
92
|
+
help_text="Number of times to retry the task if it fails",
|
|
93
|
+
validators=[django.core.validators.MaxValueValidator(5)],
|
|
94
|
+
verbose_name="Retries",
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
"timeout",
|
|
99
|
+
models.CharField(
|
|
100
|
+
default="60s",
|
|
101
|
+
help_text="Duration string for task timeout (e.g., '1s', '5m', '2h'). See Max HTTP Connection Timeout on QStash pricing page for allowed values for your Upstash account.",
|
|
102
|
+
max_length=10,
|
|
103
|
+
validators=[
|
|
104
|
+
django_qstash.schedules.validators.validate_duration_string
|
|
105
|
+
],
|
|
106
|
+
verbose_name="Timeout",
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
("is_active", models.BooleanField(default=True)),
|
|
110
|
+
(
|
|
111
|
+
"active_at",
|
|
112
|
+
models.DateTimeField(
|
|
113
|
+
blank=True, default=django.utils.timezone.now, null=True
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
("is_paused", models.BooleanField(default=False)),
|
|
117
|
+
("paused_at", models.DateTimeField(blank=True, null=True)),
|
|
118
|
+
("is_resumed", models.BooleanField(default=False)),
|
|
119
|
+
("resumed_at", models.DateTimeField(blank=True, null=True)),
|
|
120
|
+
],
|
|
121
|
+
),
|
|
122
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Generated by Django 5.1.4 on 2025-01-02 07:44
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.db import migrations
|
|
6
|
+
from django.db import models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
dependencies = [
|
|
11
|
+
("django_qstash_schedules", "0001_initial"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.AddField(
|
|
16
|
+
model_name="taskschedule",
|
|
17
|
+
name="updated_at",
|
|
18
|
+
field=models.DateTimeField(auto_now=True),
|
|
19
|
+
),
|
|
20
|
+
]
|
|
File without changes
|