django-qstash 0.0.5__py3-none-any.whl → 0.0.7__py3-none-any.whl
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/__init__.py +1 -1
- django_qstash/callbacks.py +15 -0
- django_qstash/client.py +7 -0
- django_qstash/discovery/__init__.py +0 -0
- django_qstash/discovery/fields.py +38 -0
- django_qstash/discovery/models.py +25 -0
- django_qstash/discovery/utils.py +93 -0
- django_qstash/discovery/validators.py +24 -0
- django_qstash/management/__init__.py +0 -0
- django_qstash/management/commands/task_schedules.py +116 -0
- django_qstash/schedules/__init__.py +0 -0
- django_qstash/schedules/admin.py +66 -0
- django_qstash/schedules/apps.py +13 -0
- django_qstash/schedules/exceptions.py +9 -0
- django_qstash/schedules/formatters.py +37 -0
- django_qstash/schedules/forms.py +24 -0
- django_qstash/schedules/migrations/0001_initial.py +122 -0
- django_qstash/schedules/migrations/0002_taskschedule_updated_at.py +20 -0
- django_qstash/schedules/migrations/__init__.py +0 -0
- django_qstash/schedules/models.py +120 -0
- django_qstash/schedules/services.py +64 -0
- django_qstash/schedules/signals.py +21 -0
- django_qstash/schedules/validators.py +29 -0
- django_qstash/settings.py +17 -0
- django_qstash/tasks.py +5 -25
- {django_qstash-0.0.5.dist-info → django_qstash-0.0.7.dist-info}/METADATA +1 -1
- django_qstash-0.0.7.dist-info/RECORD +42 -0
- django_qstash-0.0.5.dist-info/RECORD +0 -19
- {django_qstash-0.0.5.dist-info → django_qstash-0.0.7.dist-info}/WHEEL +0 -0
- {django_qstash-0.0.5.dist-info → django_qstash-0.0.7.dist-info}/top_level.txt +0 -0
django_qstash/__init__.py
CHANGED
|
@@ -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}/"
|
django_qstash/client.py
ADDED
|
File without changes
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.validators import MaxValueValidator
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
from django_qstash.discovery.models import TaskField
|
|
11
|
+
from django_qstash.schedules.validators import validate_duration_string
|
|
12
|
+
|
|
13
|
+
DJANGO_QSTASH_DOMAIN = getattr(settings, "DJANGO_QSTASH_DOMAIN", None)
|
|
14
|
+
DJANGO_QSTASH_WEBHOOK_PATH = getattr(
|
|
15
|
+
settings, "DJANGO_QSTASH_WEBHOOK_PATH", "/qstash/webhook/"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskSchedule(models.Model):
|
|
20
|
+
"""
|
|
21
|
+
A model that represents a QStash Schedule for any given django-qstash Task.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
schedule_id = models.CharField(
|
|
25
|
+
max_length=255,
|
|
26
|
+
unique=True,
|
|
27
|
+
blank=True,
|
|
28
|
+
null=True,
|
|
29
|
+
verbose_name="Schedule ID",
|
|
30
|
+
help_text="The schedule ID stored in QStash",
|
|
31
|
+
db_index=True,
|
|
32
|
+
)
|
|
33
|
+
name = models.CharField(
|
|
34
|
+
max_length=200,
|
|
35
|
+
verbose_name="Name",
|
|
36
|
+
help_text="Short Description For This Task Schedule",
|
|
37
|
+
)
|
|
38
|
+
task = TaskField()
|
|
39
|
+
task_name = models.CharField(
|
|
40
|
+
max_length=255, help_text="Original Python location of task", blank=True
|
|
41
|
+
)
|
|
42
|
+
args = models.JSONField(
|
|
43
|
+
blank=True,
|
|
44
|
+
default=list,
|
|
45
|
+
verbose_name="Positional Arguments",
|
|
46
|
+
help_text='JSON encoded positional arguments (Example: ["arg1", "arg2"])',
|
|
47
|
+
)
|
|
48
|
+
kwargs = models.JSONField(
|
|
49
|
+
blank=True,
|
|
50
|
+
default=dict,
|
|
51
|
+
verbose_name="Keyword Arguments",
|
|
52
|
+
help_text='JSON encoded keyword arguments (Example: {"argument": "value"})',
|
|
53
|
+
)
|
|
54
|
+
# Configured for Upstash Cron
|
|
55
|
+
# https://upstash.com/docs/qstash/api/schedules/create#param-upstash-cron
|
|
56
|
+
cron = models.CharField(
|
|
57
|
+
max_length=255,
|
|
58
|
+
default="*/5 * * * *",
|
|
59
|
+
verbose_name="Cron Expression",
|
|
60
|
+
help_text="Cron expression for scheduling the task",
|
|
61
|
+
)
|
|
62
|
+
# Configured for Upstash Retries
|
|
63
|
+
# https://upstash.com/docs/qstash/api/schedules/create#param-upstash-retries
|
|
64
|
+
retries = models.IntegerField(
|
|
65
|
+
default=3,
|
|
66
|
+
verbose_name="Retries",
|
|
67
|
+
validators=[MaxValueValidator(5)],
|
|
68
|
+
help_text="Number of times to retry the task if it fails",
|
|
69
|
+
)
|
|
70
|
+
# Configured for Upstash Timeout
|
|
71
|
+
# https://upstash.com/docs/qstash/api/schedules/create#param-upstash-timeout
|
|
72
|
+
timeout = models.CharField(
|
|
73
|
+
max_length=10,
|
|
74
|
+
default="60s",
|
|
75
|
+
verbose_name="Timeout",
|
|
76
|
+
validators=[validate_duration_string],
|
|
77
|
+
help_text="Duration string for task timeout (e.g., '1s', '5m', '2h'). "
|
|
78
|
+
"See Max HTTP Connection Timeout on QStash pricing page for allowed values for your Upstash account.",
|
|
79
|
+
)
|
|
80
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
81
|
+
is_active = models.BooleanField(default=True)
|
|
82
|
+
active_at = models.DateTimeField(null=True, blank=True, default=timezone.now)
|
|
83
|
+
is_paused = models.BooleanField(default=False)
|
|
84
|
+
paused_at = models.DateTimeField(null=True, blank=True)
|
|
85
|
+
is_resumed = models.BooleanField(default=False)
|
|
86
|
+
resumed_at = models.DateTimeField(null=True, blank=True)
|
|
87
|
+
|
|
88
|
+
def save(self, *args, **kwargs):
|
|
89
|
+
current_task_name = self.task
|
|
90
|
+
if not self.pk or self.task_name != current_task_name:
|
|
91
|
+
self.task_name = current_task_name
|
|
92
|
+
|
|
93
|
+
if not self.is_active:
|
|
94
|
+
self.is_paused = True
|
|
95
|
+
self.is_resumed = False
|
|
96
|
+
self.paused_at = timezone.now()
|
|
97
|
+
self.resumed_at = None
|
|
98
|
+
self.active_at = None
|
|
99
|
+
self.is_active = False
|
|
100
|
+
elif self.is_active:
|
|
101
|
+
self.is_paused = False
|
|
102
|
+
self.is_resumed = True
|
|
103
|
+
self.paused_at = None
|
|
104
|
+
self.resumed_at = timezone.now()
|
|
105
|
+
self.active_at = timezone.now()
|
|
106
|
+
super().save(*args, **kwargs)
|
|
107
|
+
|
|
108
|
+
def did_just_resume(self, delta_seconds: int = 60) -> bool:
|
|
109
|
+
if not self.is_resumed or not self.resumed_at:
|
|
110
|
+
return False
|
|
111
|
+
now = timezone.now()
|
|
112
|
+
delta_window = now - timedelta(seconds=delta_seconds)
|
|
113
|
+
return self.resumed_at >= delta_window
|
|
114
|
+
|
|
115
|
+
def did_just_pause(self, delta_seconds: int = 60) -> bool:
|
|
116
|
+
if not self.is_paused or not self.paused_at:
|
|
117
|
+
return False
|
|
118
|
+
now = timezone.now()
|
|
119
|
+
delta_window = now - timedelta(seconds=delta_seconds)
|
|
120
|
+
return self.paused_at >= delta_window
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from django.db import transaction
|
|
6
|
+
from qstash.schedule import Schedule
|
|
7
|
+
|
|
8
|
+
from django_qstash.client import qstash_client
|
|
9
|
+
from django_qstash.schedules.formatters import format_task_schedule_for_qstash
|
|
10
|
+
from django_qstash.schedules.models import TaskSchedule
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@transaction.atomic
|
|
16
|
+
def sync_task_schedule_instance_to_qstash(instance: TaskSchedule) -> TaskSchedule:
|
|
17
|
+
"""Sync a task schedule to QStash.
|
|
18
|
+
|
|
19
|
+
Creates a new schedule if none exists, handles pause/resume state changes,
|
|
20
|
+
and updates existing schedules when active.
|
|
21
|
+
"""
|
|
22
|
+
data = format_task_schedule_for_qstash(instance)
|
|
23
|
+
qstash_client.schedule.create(**data)
|
|
24
|
+
|
|
25
|
+
sync_state_changes(instance)
|
|
26
|
+
|
|
27
|
+
return instance
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sync_state_changes(instance: TaskSchedule) -> None:
|
|
31
|
+
"""Handle pause/resume state changes."""
|
|
32
|
+
if instance.did_just_resume():
|
|
33
|
+
try:
|
|
34
|
+
qstash_client.schedule.resume(instance.schedule_id)
|
|
35
|
+
except Exception:
|
|
36
|
+
logger.exception("Failed to resume schedule %s", instance.schedule_id)
|
|
37
|
+
elif instance.did_just_pause():
|
|
38
|
+
try:
|
|
39
|
+
qstash_client.schedule.pause(instance.schedule_id)
|
|
40
|
+
except Exception:
|
|
41
|
+
logger.exception("Failed to pause schedule %s", instance.schedule_id)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_task_schedule_from_qstash(
|
|
45
|
+
instance: TaskSchedule, as_dict: bool = False
|
|
46
|
+
) -> Schedule | dict | None:
|
|
47
|
+
"""Get a schedule from QStash."""
|
|
48
|
+
try:
|
|
49
|
+
response = qstash_client.schedule.get(instance.schedule_id)
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.exception("Failed to lookup schedule %s", instance.schedule_id)
|
|
52
|
+
return None
|
|
53
|
+
if response:
|
|
54
|
+
return response.__dict__ if as_dict else response
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def delete_task_schedule_from_qstash(instance: TaskSchedule) -> None:
|
|
59
|
+
"""Delete a schedule from QStash."""
|
|
60
|
+
try:
|
|
61
|
+
qstash_client.schedule.delete(instance.schedule_id)
|
|
62
|
+
except Exception:
|
|
63
|
+
logger.exception("Failed to delete schedule %s", instance.schedule_id)
|
|
64
|
+
return
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.db.models.signals import post_save
|
|
4
|
+
from django.db.models.signals import pre_delete
|
|
5
|
+
from django.dispatch import receiver
|
|
6
|
+
|
|
7
|
+
from django_qstash.schedules import services
|
|
8
|
+
from django_qstash.schedules.models import TaskSchedule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@receiver(post_save, sender=TaskSchedule)
|
|
12
|
+
def sync_schedule_to_qstash_receiver(sender, instance, created, **kwargs):
|
|
13
|
+
"""
|
|
14
|
+
Sync the django-qstash TaskSchedule to QStash on save.
|
|
15
|
+
"""
|
|
16
|
+
services.sync_task_schedule_instance_to_qstash(instance)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@receiver(pre_delete, sender=TaskSchedule)
|
|
20
|
+
def delete_schedule_from_qstash_receiver(sender, instance, **kwargs):
|
|
21
|
+
services.delete_task_schedule_from_qstash(instance)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from django_qstash.schedules.exceptions import InvalidDurationStringValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_duration_string(value):
|
|
9
|
+
if not re.match(r"^\d+[smhd]$", value):
|
|
10
|
+
raise InvalidDurationStringValidationError(
|
|
11
|
+
'Invalid duration format. Must be a number followed by s (seconds), m (minutes), h (hours), or d (days). E.g., "60s", "5m", "2h", "7d"'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Extract number and unit
|
|
15
|
+
number = int(value[:-1])
|
|
16
|
+
unit = value[-1]
|
|
17
|
+
|
|
18
|
+
# Convert to days
|
|
19
|
+
days = {
|
|
20
|
+
"s": number / (24 * 60 * 60), # seconds to days
|
|
21
|
+
"m": number / (24 * 60), # minutes to days
|
|
22
|
+
"h": number / 24, # hours to days
|
|
23
|
+
"d": number, # already in days
|
|
24
|
+
}[unit]
|
|
25
|
+
|
|
26
|
+
if days > 7:
|
|
27
|
+
raise InvalidDurationStringValidationError(
|
|
28
|
+
"Duration too long. Maximum allowed: 7 days (equivalent to: 604800s, 10080m, 168h, 7d)"
|
|
29
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
|
|
7
|
+
QSTASH_TOKEN = getattr(settings, "QSTASH_TOKEN", None)
|
|
8
|
+
DJANGO_QSTASH_DOMAIN = getattr(settings, "DJANGO_QSTASH_DOMAIN", None)
|
|
9
|
+
DJANGO_QSTASH_WEBHOOK_PATH = getattr(
|
|
10
|
+
settings, "DJANGO_QSTASH_WEBHOOK_PATH", "/qstash/webhook/"
|
|
11
|
+
)
|
|
12
|
+
if not QSTASH_TOKEN or not DJANGO_QSTASH_DOMAIN:
|
|
13
|
+
warnings.warn(
|
|
14
|
+
"QSTASH_TOKEN and DJANGO_QSTASH_DOMAIN should be set for QStash functionality",
|
|
15
|
+
RuntimeWarning,
|
|
16
|
+
stacklevel=2,
|
|
17
|
+
)
|
django_qstash/tasks.py
CHANGED
|
@@ -1,28 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
-
import warnings
|
|
5
4
|
from typing import Any
|
|
6
5
|
from typing import Callable
|
|
7
6
|
|
|
8
|
-
from django.conf import settings
|
|
9
7
|
from django.core.exceptions import ImproperlyConfigured
|
|
10
|
-
from qstash import QStash
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
if not QSTASH_TOKEN or not DJANGO_QSTASH_DOMAIN:
|
|
18
|
-
warnings.warn(
|
|
19
|
-
"QSTASH_TOKEN and DJANGO_QSTASH_DOMAIN should be set for QStash functionality",
|
|
20
|
-
RuntimeWarning,
|
|
21
|
-
stacklevel=2,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
# Initialize QStash client once
|
|
25
|
-
qstash_client = QStash(QSTASH_TOKEN)
|
|
9
|
+
from django_qstash.callbacks import get_callback_url
|
|
10
|
+
from django_qstash.client import qstash_client
|
|
11
|
+
from django_qstash.settings import DJANGO_QSTASH_DOMAIN
|
|
12
|
+
from django_qstash.settings import QSTASH_TOKEN
|
|
26
13
|
|
|
27
14
|
|
|
28
15
|
class QStashTask:
|
|
@@ -39,8 +26,6 @@ class QStashTask:
|
|
|
39
26
|
self.delay_seconds = delay_seconds
|
|
40
27
|
self.deduplicated = deduplicated
|
|
41
28
|
self.options = options
|
|
42
|
-
self.callback_domain = DJANGO_QSTASH_DOMAIN.rstrip("/")
|
|
43
|
-
self.webhook_path = DJANGO_QSTASH_WEBHOOK_PATH.strip("/")
|
|
44
29
|
|
|
45
30
|
if func is not None:
|
|
46
31
|
functools.update_wrapper(self, func)
|
|
@@ -84,12 +69,7 @@ class QStashTask:
|
|
|
84
69
|
"options": self.options,
|
|
85
70
|
}
|
|
86
71
|
|
|
87
|
-
|
|
88
|
-
callback_domain = self.callback_domain
|
|
89
|
-
if not callback_domain.startswith(("http://", "https://")):
|
|
90
|
-
callback_domain = f"https://{callback_domain}"
|
|
91
|
-
|
|
92
|
-
url = f"{callback_domain}/{self.webhook_path}/"
|
|
72
|
+
url = get_callback_url()
|
|
93
73
|
# Send to QStash using the official SDK
|
|
94
74
|
response = qstash_client.message.publish_json(
|
|
95
75
|
url=url,
|
|
@@ -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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
django_qstash/__init__.py,sha256=y677U8dn62nPF5Er1ROJB-qzlukk7PqiCT2_7iL4osg,117
|
|
2
|
+
django_qstash/callbacks.py,sha256=VG5tGdNzAmUEh7NlpghrxhWvnpRNXZucWmWwxaemw0M,530
|
|
3
|
+
django_qstash/client.py,sha256=cgHf-g6lDAltY_Vt6GUVJNY2JSz1czBOHL-WVkkLs2M,149
|
|
4
|
+
django_qstash/exceptions.py,sha256=pH6kKRJFIVFkDHUJQ9yRWmtGdBBSXpNAwMSFuNzMgPw,392
|
|
5
|
+
django_qstash/handlers.py,sha256=mmm8TJOqV3j1rQXooNOa128gtmALXFNCAaDZ5xwIcuw,4950
|
|
6
|
+
django_qstash/settings.py,sha256=YvpXMo1AdIWvbotISWJmhg0vrW3A3UQ4BieNzMfRC7Y,524
|
|
7
|
+
django_qstash/tasks.py,sha256=VYv7m0Uz1v8kPBegGHam5AH0Hcc-n53BZz3IdQcAqsk,4462
|
|
8
|
+
django_qstash/utils.py,sha256=wrTU30cobO2di18BNEFtKD4ih2euf7eQNpg6p6TkQ1Y,1185
|
|
9
|
+
django_qstash/views.py,sha256=H32f_jGnlwOTO0YG9znNo2b-GRYZ8TM-Wt0T62SGdXM,639
|
|
10
|
+
django_qstash/discovery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
django_qstash/discovery/fields.py,sha256=qZDKMpl6VMRJL0FkGwXkeN_rcCT32cupDaXJOz-bFzs,1226
|
|
12
|
+
django_qstash/discovery/models.py,sha256=9ml9lTKEqEKx2uqYvejZw_BjdnowgFOPE7rYNt_8E9A,685
|
|
13
|
+
django_qstash/discovery/utils.py,sha256=k-QfOGElehCM7tV5v0FJAfwKqJWjez4xFf2RUyxIgP0,3218
|
|
14
|
+
django_qstash/discovery/validators.py,sha256=NNCf7ltW--sTw0w8FWwTp-4PtiS35U6DITEApx-D7X4,645
|
|
15
|
+
django_qstash/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
django_qstash/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
django_qstash/management/commands/clear_stale_results.py,sha256=stHCnvC1ER_rZXKUK-YVUgazC2q04eGIHjo_BrPSDEI,2096
|
|
18
|
+
django_qstash/management/commands/task_schedules.py,sha256=b9lJ1vjQKHyGzWAo9csGwE_oaKfgcSC8bPFLt9Ry6WE,4278
|
|
19
|
+
django_qstash/results/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
django_qstash/results/admin.py,sha256=q9fn3lfn0gviMfiimYij0wBCYww7FxyrOfGPr1NvntA,434
|
|
21
|
+
django_qstash/results/apps.py,sha256=4me4cg5yeoeSJTphkHYzGMJUfGucT47FNIUMYu5gmIo,275
|
|
22
|
+
django_qstash/results/models.py,sha256=aEiAhGJOuLRtjibUw6xdQqUt3eYKLqY2as4I4QSrF5U,1047
|
|
23
|
+
django_qstash/results/services.py,sha256=HvNp5D1tQ__nz4LVUTAGxuyLl_dnlBps4pJ6E9HD2kA,991
|
|
24
|
+
django_qstash/results/migrations/0001_initial.py,sha256=A90SKgWmBf4SIJYG1Jh6-b_81Ia1zIzGj3Bfl1O4-kg,1902
|
|
25
|
+
django_qstash/results/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
django_qstash/schedules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
django_qstash/schedules/admin.py,sha256=ToFkdGfN3smNctTduLQpwxZ5yW20jW4HKRr7x1UilIk,1673
|
|
28
|
+
django_qstash/schedules/apps.py,sha256=zTyTH6xTZPKsEC2MlJcQBDp2ZiRWdMfq2j2jGbpBLH0,360
|
|
29
|
+
django_qstash/schedules/exceptions.py,sha256=fvXArrK24ZBGITijuJ_I5xixURmFrJpQ6Dbi2bLNqCQ,195
|
|
30
|
+
django_qstash/schedules/formatters.py,sha256=KMt1457z4Mp0Mob2IxgTBHmye_mRfcLn7Rdf7ThvbEg,1157
|
|
31
|
+
django_qstash/schedules/forms.py,sha256=S5Wy2mZha2-th4oAQLjkC9JytfXFNJcL1j18u6rslxI,524
|
|
32
|
+
django_qstash/schedules/models.py,sha256=WS4b5ssh07ekiTe5_y4phLKGl7uTXHJBiF4W47vkGec,4357
|
|
33
|
+
django_qstash/schedules/services.py,sha256=DqwYn2kSEJ7f06WskmWRWIzeadV70aHURgWGvmg9FK0,2055
|
|
34
|
+
django_qstash/schedules/signals.py,sha256=g1aRAbZx-stnvD589mZagR6I27E56064fUyWsxKitR4,696
|
|
35
|
+
django_qstash/schedules/validators.py,sha256=i8IEjnRVk-iysmqvT_kbPYpxTKCQWoX9P1JHcL3zkhI,925
|
|
36
|
+
django_qstash/schedules/migrations/0001_initial.py,sha256=66cA8xnJV3h7QgzCaOiv-Nu3Xl9IdZQPgQKhxyW3bs4,4516
|
|
37
|
+
django_qstash/schedules/migrations/0002_taskschedule_updated_at.py,sha256=6hZO0a9P2ZpOROkk7O5UXBhahghU0QfxZl4E-c3HKGw,459
|
|
38
|
+
django_qstash/schedules/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
39
|
+
django_qstash-0.0.7.dist-info/METADATA,sha256=VXoUQawPhDe9DL1H6D1RxonOPVzzG-yGS1gmfWLjM0Q,12307
|
|
40
|
+
django_qstash-0.0.7.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
41
|
+
django_qstash-0.0.7.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
|
|
42
|
+
django_qstash-0.0.7.dist-info/RECORD,,
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
django_qstash/__init__.py,sha256=iG3lEbUQGkNq3qifS8onh9F6HGZpf4IQfVwGJNFjJUU,117
|
|
2
|
-
django_qstash/exceptions.py,sha256=pH6kKRJFIVFkDHUJQ9yRWmtGdBBSXpNAwMSFuNzMgPw,392
|
|
3
|
-
django_qstash/handlers.py,sha256=mmm8TJOqV3j1rQXooNOa128gtmALXFNCAaDZ5xwIcuw,4950
|
|
4
|
-
django_qstash/tasks.py,sha256=tiBJz8BIOYRD6MN0k4_1ncKCjLlptP3BvTad9lz7YVo,5220
|
|
5
|
-
django_qstash/utils.py,sha256=wrTU30cobO2di18BNEFtKD4ih2euf7eQNpg6p6TkQ1Y,1185
|
|
6
|
-
django_qstash/views.py,sha256=H32f_jGnlwOTO0YG9znNo2b-GRYZ8TM-Wt0T62SGdXM,639
|
|
7
|
-
django_qstash/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
django_qstash/management/commands/clear_stale_results.py,sha256=stHCnvC1ER_rZXKUK-YVUgazC2q04eGIHjo_BrPSDEI,2096
|
|
9
|
-
django_qstash/results/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
django_qstash/results/admin.py,sha256=q9fn3lfn0gviMfiimYij0wBCYww7FxyrOfGPr1NvntA,434
|
|
11
|
-
django_qstash/results/apps.py,sha256=4me4cg5yeoeSJTphkHYzGMJUfGucT47FNIUMYu5gmIo,275
|
|
12
|
-
django_qstash/results/models.py,sha256=aEiAhGJOuLRtjibUw6xdQqUt3eYKLqY2as4I4QSrF5U,1047
|
|
13
|
-
django_qstash/results/services.py,sha256=HvNp5D1tQ__nz4LVUTAGxuyLl_dnlBps4pJ6E9HD2kA,991
|
|
14
|
-
django_qstash/results/migrations/0001_initial.py,sha256=A90SKgWmBf4SIJYG1Jh6-b_81Ia1zIzGj3Bfl1O4-kg,1902
|
|
15
|
-
django_qstash/results/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
-
django_qstash-0.0.5.dist-info/METADATA,sha256=G0tRPosSRgvu7fxwG2BBUkA6WKpFb6xmHNjaoqGRLKQ,12307
|
|
17
|
-
django_qstash-0.0.5.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
18
|
-
django_qstash-0.0.5.dist-info/top_level.txt,sha256=AlV3WSK1A0ZvKuCLsINtIJhJW8zo7SEB-D3_RAjZ0hI,14
|
|
19
|
-
django_qstash-0.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|