django-qstash 0.0.4__tar.gz → 0.0.6__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.

Files changed (55) hide show
  1. {django_qstash-0.0.4 → django_qstash-0.0.6}/PKG-INFO +1 -1
  2. {django_qstash-0.0.4 → django_qstash-0.0.6}/pyproject.toml +3 -1
  3. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/__init__.py +1 -1
  4. django_qstash-0.0.6/src/django_qstash/callbacks.py +15 -0
  5. django_qstash-0.0.6/src/django_qstash/client.py +7 -0
  6. django_qstash-0.0.6/src/django_qstash/discovery/fields.py +38 -0
  7. django_qstash-0.0.6/src/django_qstash/discovery/models.py +25 -0
  8. django_qstash-0.0.6/src/django_qstash/discovery/utils.py +93 -0
  9. django_qstash-0.0.6/src/django_qstash/discovery/validators.py +24 -0
  10. django_qstash-0.0.6/src/django_qstash/management/commands/task_schedules.py +112 -0
  11. django_qstash-0.0.6/src/django_qstash/results/migrations/__init__.py +0 -0
  12. django_qstash-0.0.6/src/django_qstash/schedules/__init__.py +0 -0
  13. django_qstash-0.0.6/src/django_qstash/schedules/admin.py +66 -0
  14. django_qstash-0.0.6/src/django_qstash/schedules/apps.py +13 -0
  15. django_qstash-0.0.6/src/django_qstash/schedules/exceptions.py +9 -0
  16. django_qstash-0.0.6/src/django_qstash/schedules/formatters.py +37 -0
  17. django_qstash-0.0.6/src/django_qstash/schedules/forms.py +24 -0
  18. django_qstash-0.0.6/src/django_qstash/schedules/migrations/0001_initial.py +122 -0
  19. django_qstash-0.0.6/src/django_qstash/schedules/migrations/0002_taskschedule_updated_at.py +20 -0
  20. django_qstash-0.0.6/src/django_qstash/schedules/migrations/__init__.py +0 -0
  21. django_qstash-0.0.6/src/django_qstash/schedules/models.py +120 -0
  22. django_qstash-0.0.6/src/django_qstash/schedules/services.py +64 -0
  23. django_qstash-0.0.6/src/django_qstash/schedules/signals.py +21 -0
  24. django_qstash-0.0.6/src/django_qstash/schedules/validators.py +29 -0
  25. django_qstash-0.0.6/src/django_qstash/settings.py +17 -0
  26. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/tasks.py +5 -25
  27. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash.egg-info/PKG-INFO +1 -1
  28. django_qstash-0.0.6/src/django_qstash.egg-info/SOURCES.txt +52 -0
  29. django_qstash-0.0.6/tests/test_callbacks.py +62 -0
  30. django_qstash-0.0.6/tests/test_settings.py +29 -0
  31. django_qstash-0.0.4/src/django_qstash.egg-info/SOURCES.txt +0 -28
  32. {django_qstash-0.0.4 → django_qstash-0.0.6}/README.md +0 -0
  33. {django_qstash-0.0.4 → django_qstash-0.0.6}/setup.cfg +0 -0
  34. {django_qstash-0.0.4/src/django_qstash/management/commands → django_qstash-0.0.6/src/django_qstash/discovery}/__init__.py +0 -0
  35. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/exceptions.py +0 -0
  36. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/handlers.py +0 -0
  37. {django_qstash-0.0.4/src/django_qstash/results → django_qstash-0.0.6/src/django_qstash/management/commands}/__init__.py +0 -0
  38. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/management/commands/clear_stale_results.py +0 -0
  39. {django_qstash-0.0.4/src/django_qstash/results/migrations → django_qstash-0.0.6/src/django_qstash/results}/__init__.py +0 -0
  40. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/results/admin.py +0 -0
  41. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/results/apps.py +0 -0
  42. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/results/migrations/0001_initial.py +0 -0
  43. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/results/models.py +0 -0
  44. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/results/services.py +0 -0
  45. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/utils.py +0 -0
  46. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash/views.py +0 -0
  47. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash.egg-info/dependency_links.txt +0 -0
  48. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash.egg-info/requires.txt +0 -0
  49. {django_qstash-0.0.4 → django_qstash-0.0.6}/src/django_qstash.egg-info/top_level.txt +0 -0
  50. {django_qstash-0.0.4 → django_qstash-0.0.6}/tests/test_exceptions.py +0 -0
  51. {django_qstash-0.0.4 → django_qstash-0.0.6}/tests/test_handlers.py +0 -0
  52. {django_qstash-0.0.4 → django_qstash-0.0.6}/tests/test_results_models.py +0 -0
  53. {django_qstash-0.0.4 → django_qstash-0.0.6}/tests/test_tasks.py +0 -0
  54. {django_qstash-0.0.4 → django_qstash-0.0.6}/tests/test_utils.py +0 -0
  55. {django_qstash-0.0.4 → django_qstash-0.0.6}/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.4
3
+ Version: 0.0.6
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.4"
9
+ version = "0.0.6"
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]
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.0.4"
3
+ __version__ = "0.0.6"
4
4
 
5
5
  from .tasks import shared_task
6
6
 
@@ -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,7 @@
1
+ from __future__ import annotations
2
+
3
+ from qstash import QStash
4
+
5
+ from django_qstash.settings import QSTASH_TOKEN
6
+
7
+ qstash_client = QStash(QSTASH_TOKEN)
@@ -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,112 @@
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.schedules.client import QStashScheduleClient
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Command(BaseCommand):
16
+ """Management command to list and sync QStash schedules."""
17
+
18
+ help = "List and sync schedules from QStash"
19
+
20
+ def add_arguments(self, parser) -> None:
21
+ parser.add_argument(
22
+ "--list",
23
+ action="store_true",
24
+ help="List schedules from QStash",
25
+ )
26
+ parser.add_argument(
27
+ "--sync",
28
+ action="store_true",
29
+ help="Sync schedules from QStash to local database",
30
+ )
31
+
32
+ def get_task_schedule_model(self) -> models.Model | None:
33
+ """Get the TaskSchedule model if available."""
34
+ try:
35
+ return apps.get_model("django_qstash_schedules", "TaskSchedule")
36
+ except LookupError:
37
+ self.stdout.write(
38
+ self.style.ERROR(
39
+ "Django QStash Schedules not installed.\n"
40
+ "Add `django_qstash.schedules` to INSTALLED_APPS and run migrations."
41
+ )
42
+ )
43
+ return None
44
+
45
+ def sync_schedules(self, schedules: list) -> None:
46
+ """Sync remote schedules to local database."""
47
+ TaskSchedule = self.get_task_schedule_model()
48
+ if not TaskSchedule:
49
+ return
50
+
51
+ for schedule in schedules:
52
+ try:
53
+ body = json.loads(schedule.body)
54
+ task_name = body.get("task_name", "Unnamed Task")
55
+ function = f"{body['module']}.{body['function']}"
56
+
57
+ obj, created = TaskSchedule.objects.update_or_create(
58
+ schedule_id=schedule.schedule_id,
59
+ defaults={
60
+ "name": task_name,
61
+ "task": function,
62
+ "cron": schedule.cron,
63
+ "args": body.get("args", []),
64
+ "kwargs": body.get("kwargs", {}),
65
+ },
66
+ )
67
+ status = "Created" if created else "Updated"
68
+ logger.info(
69
+ "%s schedule: %s (%s)", status, task_name, schedule.schedule_id
70
+ )
71
+ except Exception:
72
+ logger.exception("Failed to sync schedule %s", schedule.schedule_id)
73
+
74
+ def handle(self, *args, **options) -> None:
75
+ if not (options.get("sync") or options.get("list")):
76
+ self.stdout.write(
77
+ self.style.ERROR("Please specify either --list or --sync option")
78
+ )
79
+ return
80
+
81
+ try:
82
+ client = QStashScheduleClient()
83
+ destination = client._get_callback_url()
84
+ schedules = client.list_schedules()
85
+
86
+ self.stdout.write(
87
+ self.style.SUCCESS(
88
+ f"Found {len(schedules)} remote schedules based on destination: {destination}"
89
+ )
90
+ )
91
+
92
+ for schedule in schedules:
93
+ body = json.loads(schedule.body)
94
+ task_name = body.get("task_name", "Unnamed Task")
95
+ function = f"{body['module']}.{body['function']}"
96
+
97
+ self.stdout.write(
98
+ f"\nSchedule ID: {schedule.schedule_id}"
99
+ f"\n Task: {task_name} ({function})"
100
+ f"\n Cron: {schedule.cron}"
101
+ f"\n Destination: {schedule.destination}"
102
+ f"\n Retries: {schedule.retries}"
103
+ f"\n Status: {'Paused' if schedule.paused else 'Active'}"
104
+ )
105
+
106
+ if options.get("sync"):
107
+ user_input = input("Do you want to sync remote schedules? (y/n): ")
108
+ if user_input.lower() == "y":
109
+ self.sync_schedules(schedules)
110
+
111
+ except Exception as e:
112
+ self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}"))
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+ from django.core.exceptions import ValidationError
4
+
5
+
6
+ class InvalidDurationStringValidationError(ValidationError):
7
+ """Invalid duration string."""
8
+
9
+ pass
@@ -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
+ ]
@@ -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
+ )
@@ -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
- QSTASH_TOKEN = getattr(settings, "QSTASH_TOKEN", None)
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
- 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
- # Ensure callback URL is properly formatted
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.4
3
+ Version: 0.0.6
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,52 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/django_qstash/__init__.py
4
+ src/django_qstash/callbacks.py
5
+ src/django_qstash/client.py
6
+ src/django_qstash/exceptions.py
7
+ src/django_qstash/handlers.py
8
+ src/django_qstash/settings.py
9
+ src/django_qstash/tasks.py
10
+ src/django_qstash/utils.py
11
+ src/django_qstash/views.py
12
+ src/django_qstash.egg-info/PKG-INFO
13
+ src/django_qstash.egg-info/SOURCES.txt
14
+ src/django_qstash.egg-info/dependency_links.txt
15
+ src/django_qstash.egg-info/requires.txt
16
+ src/django_qstash.egg-info/top_level.txt
17
+ src/django_qstash/discovery/__init__.py
18
+ src/django_qstash/discovery/fields.py
19
+ src/django_qstash/discovery/models.py
20
+ src/django_qstash/discovery/utils.py
21
+ src/django_qstash/discovery/validators.py
22
+ src/django_qstash/management/commands/__init__.py
23
+ src/django_qstash/management/commands/clear_stale_results.py
24
+ src/django_qstash/management/commands/task_schedules.py
25
+ src/django_qstash/results/__init__.py
26
+ src/django_qstash/results/admin.py
27
+ src/django_qstash/results/apps.py
28
+ src/django_qstash/results/models.py
29
+ src/django_qstash/results/services.py
30
+ src/django_qstash/results/migrations/0001_initial.py
31
+ src/django_qstash/results/migrations/__init__.py
32
+ src/django_qstash/schedules/__init__.py
33
+ src/django_qstash/schedules/admin.py
34
+ src/django_qstash/schedules/apps.py
35
+ src/django_qstash/schedules/exceptions.py
36
+ src/django_qstash/schedules/formatters.py
37
+ src/django_qstash/schedules/forms.py
38
+ src/django_qstash/schedules/models.py
39
+ src/django_qstash/schedules/services.py
40
+ src/django_qstash/schedules/signals.py
41
+ src/django_qstash/schedules/validators.py
42
+ src/django_qstash/schedules/migrations/0001_initial.py
43
+ src/django_qstash/schedules/migrations/0002_taskschedule_updated_at.py
44
+ src/django_qstash/schedules/migrations/__init__.py
45
+ tests/test_callbacks.py
46
+ tests/test_exceptions.py
47
+ tests/test_handlers.py
48
+ tests/test_results_models.py
49
+ tests/test_settings.py
50
+ tests/test_tasks.py
51
+ tests/test_utils.py
52
+ tests/test_views.py
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from django_qstash.callbacks import get_callback_url
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ "domain,webhook_path,expected",
12
+ [
13
+ # Domain without protocol
14
+ (
15
+ "example.com",
16
+ "webhook",
17
+ "https://example.com/webhook/",
18
+ ),
19
+ # Domain with http protocol
20
+ (
21
+ "http://example.com",
22
+ "webhook",
23
+ "http://example.com/webhook/",
24
+ ),
25
+ # Domain with https protocol
26
+ (
27
+ "https://example.com",
28
+ "webhook",
29
+ "https://example.com/webhook/",
30
+ ),
31
+ # Domain with trailing slash
32
+ (
33
+ "example.com/",
34
+ "webhook",
35
+ "https://example.com/webhook/",
36
+ ),
37
+ # Webhook path with leading slash
38
+ (
39
+ "example.com",
40
+ "/webhook",
41
+ "https://example.com/webhook/",
42
+ ),
43
+ # Webhook path with trailing slash
44
+ (
45
+ "example.com",
46
+ "webhook/",
47
+ "https://example.com/webhook/",
48
+ ),
49
+ # Complex path
50
+ (
51
+ "example.com",
52
+ "api/v1/webhook",
53
+ "https://example.com/api/v1/webhook/",
54
+ ),
55
+ ],
56
+ )
57
+ def test_get_callback_url(domain, webhook_path, expected):
58
+ with (
59
+ patch("django_qstash.callbacks.DJANGO_QSTASH_DOMAIN", domain),
60
+ patch("django_qstash.callbacks.DJANGO_QSTASH_WEBHOOK_PATH", webhook_path),
61
+ ):
62
+ assert get_callback_url() == expected
@@ -0,0 +1,29 @@
1
+ # from django.conf import settings
2
+ # from django.test import TestCase
3
+
4
+
5
+ from __future__ import annotations
6
+
7
+ # class SettingsTestCase(TestCase):
8
+ # def test_required_settings_present(self):
9
+ # """Test that all required QStash settings are present"""
10
+ # self.assertTrue(hasattr(settings, "QSTASH_TOKEN"))
11
+ # self.assertTrue(hasattr(settings, "DJANGO_QSTASH_DOMAIN"))
12
+ # self.assertTrue(hasattr(settings, "QSTASH_CURRENT_SIGNING_KEY"))
13
+ # self.assertTrue(hasattr(settings, "QSTASH_NEXT_SIGNING_KEY"))
14
+ # def test_settings_values(self):
15
+ # """Test that settings have expected test values"""
16
+ # self.assertEqual(settings.QSTASH_TOKEN, "test-token")
17
+ # self.assertEqual(settings.DJANGO_QSTASH_DOMAIN, "example.com")
18
+ # self.assertEqual(settings.QSTASH_CURRENT_SIGNING_KEY, "current-key")
19
+ # self.assertEqual(settings.QSTASH_NEXT_SIGNING_KEY, "next-key")
20
+
21
+ # def test_required_apps_installed(self):
22
+ # """Test that required apps are in INSTALLED_APPS"""
23
+ # required_apps = [
24
+ # "django_qstash",
25
+ # "django_qstash.results",
26
+ # "django_qstash.schedules",
27
+ # ]
28
+ # for app in required_apps:
29
+ # self.assertIn(app, settings.INSTALLED_APPS)
@@ -1,28 +0,0 @@
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
File without changes
File without changes