taskiq-django 0.0.1__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.
- taskiq_django/__init__.py +9 -0
- taskiq_django/admin.py +175 -0
- taskiq_django/apps.py +6 -0
- taskiq_django/forms.py +110 -0
- taskiq_django/migrations/0001_initial.py +35 -0
- taskiq_django/migrations/__init__.py +0 -0
- taskiq_django/models.py +26 -0
- taskiq_django/py.typed +0 -0
- taskiq_django/schedule_source.py +57 -0
- taskiq_django/templates/taskiq_django/change_form.html +16 -0
- taskiq_django/templates/taskiq_django/delete_confirmation.html +25 -0
- taskiq_django/templates/taskiq_django/list_view.html +83 -0
- taskiq_django-0.0.1.dist-info/METADATA +352 -0
- taskiq_django-0.0.1.dist-info/RECORD +15 -0
- taskiq_django-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
__all__ = ["DjangoScheduleSource"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def __getattr__(name: str):
|
|
5
|
+
if name == "DjangoScheduleSource":
|
|
6
|
+
from taskiq_django.schedule_source import DjangoScheduleSource
|
|
7
|
+
|
|
8
|
+
return DjangoScheduleSource
|
|
9
|
+
raise AttributeError(f"module 'taskiq_django' has no attribute {name!r}")
|
taskiq_django/admin.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from asgiref.sync import async_to_sync
|
|
2
|
+
from django.contrib import admin, messages
|
|
3
|
+
from django.contrib.admin import helpers
|
|
4
|
+
from django.http import HttpResponseRedirect
|
|
5
|
+
from django.template.response import TemplateResponse
|
|
6
|
+
from django.urls import URLPattern, path, reverse
|
|
7
|
+
|
|
8
|
+
from taskiq_django.forms import TaskiqTaskScheduleForm
|
|
9
|
+
from taskiq_django.models import TaskiqTaskSchedule
|
|
10
|
+
from taskiq_django.schedule_source import DjangoScheduleSource
|
|
11
|
+
|
|
12
|
+
FIELDSETS = [
|
|
13
|
+
(None, {"fields": ["schedule_id", "task_name"]}),
|
|
14
|
+
("Schedule", {"fields": ["cron", "cron_offset", "time", "interval"]}),
|
|
15
|
+
("Payload", {"fields": ["args", "kwargs", "labels"], "classes": ["collapse"]}),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_source(request) -> DjangoScheduleSource:
|
|
20
|
+
scheduler = request.scope["scheduler"]
|
|
21
|
+
for source in scheduler.sources:
|
|
22
|
+
if isinstance(source, DjangoScheduleSource):
|
|
23
|
+
return source
|
|
24
|
+
raise RuntimeError("DjangoScheduleSource is not registered in the scheduler.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@admin.register(TaskiqTaskSchedule)
|
|
28
|
+
class TaskiqTaskScheduleAdmin(admin.ModelAdmin):
|
|
29
|
+
def get_urls(self) -> list[URLPattern]:
|
|
30
|
+
urls = super().get_urls()
|
|
31
|
+
wrap = self.admin_site.admin_view
|
|
32
|
+
custom_urls = [
|
|
33
|
+
path(
|
|
34
|
+
"",
|
|
35
|
+
wrap(self.external_list_view),
|
|
36
|
+
name="taskiq_django_taskiqtaskschedule_changelist",
|
|
37
|
+
),
|
|
38
|
+
path(
|
|
39
|
+
"add/",
|
|
40
|
+
wrap(self.external_add_view),
|
|
41
|
+
name="taskiq_django_taskiqtaskschedule_add",
|
|
42
|
+
),
|
|
43
|
+
path(
|
|
44
|
+
"<path:object_id>/change/",
|
|
45
|
+
wrap(self.external_change_view),
|
|
46
|
+
name="taskiq_django_taskiqtaskschedule_change",
|
|
47
|
+
),
|
|
48
|
+
path(
|
|
49
|
+
"<path:object_id>/delete/",
|
|
50
|
+
wrap(self.external_delete_view),
|
|
51
|
+
name="taskiq_django_taskiqtaskschedule_delete",
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
return custom_urls + urls
|
|
55
|
+
|
|
56
|
+
def external_list_view(self, request):
|
|
57
|
+
source = _get_source(request)
|
|
58
|
+
tasks = async_to_sync(source.get_schedules)()
|
|
59
|
+
rows = [
|
|
60
|
+
{
|
|
61
|
+
"id": task.schedule_id,
|
|
62
|
+
"task_name": task.task_name,
|
|
63
|
+
"schedule": task.cron or task.time or (task.interval and f"every {task.interval}s"),
|
|
64
|
+
"created_at": None,
|
|
65
|
+
"updated_at": None,
|
|
66
|
+
}
|
|
67
|
+
for task in tasks
|
|
68
|
+
]
|
|
69
|
+
context = {
|
|
70
|
+
**self.admin_site.each_context(request),
|
|
71
|
+
"title": self.model._meta.verbose_name_plural,
|
|
72
|
+
"opts": self.model._meta,
|
|
73
|
+
"rows": rows,
|
|
74
|
+
}
|
|
75
|
+
return TemplateResponse(request, "taskiq_django/list_view.html", context)
|
|
76
|
+
|
|
77
|
+
def external_add_view(self, request):
|
|
78
|
+
source = _get_source(request)
|
|
79
|
+
task_names = list(request.scope["broker"].get_all_tasks().keys())
|
|
80
|
+
if request.method == "POST":
|
|
81
|
+
form = TaskiqTaskScheduleForm(request.POST, task_names=task_names)
|
|
82
|
+
if form.is_valid():
|
|
83
|
+
async_to_sync(source.add_schedule)(form.to_scheduled_task())
|
|
84
|
+
messages.success(request, "Schedule created.")
|
|
85
|
+
return HttpResponseRedirect(
|
|
86
|
+
reverse("admin:taskiq_django_taskiqtaskschedule_changelist")
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
form = TaskiqTaskScheduleForm(task_names=task_names)
|
|
90
|
+
return self._render_form(request, form, title="Add taskiq schedule", object_id=None)
|
|
91
|
+
|
|
92
|
+
def external_change_view(self, request, object_id):
|
|
93
|
+
source = _get_source(request)
|
|
94
|
+
task_names = list(request.scope["broker"].get_all_tasks().keys())
|
|
95
|
+
if request.method == "POST":
|
|
96
|
+
form = TaskiqTaskScheduleForm(request.POST, task_names=task_names)
|
|
97
|
+
if form.is_valid():
|
|
98
|
+
async_to_sync(source.add_schedule)(form.to_scheduled_task())
|
|
99
|
+
messages.success(request, "Schedule updated.")
|
|
100
|
+
return HttpResponseRedirect(
|
|
101
|
+
reverse("admin:taskiq_django_taskiqtaskschedule_changelist")
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
task = self._find_schedule(source, object_id)
|
|
105
|
+
if task is None:
|
|
106
|
+
messages.error(request, f"Schedule {object_id} not found.")
|
|
107
|
+
return HttpResponseRedirect(
|
|
108
|
+
reverse("admin:taskiq_django_taskiqtaskschedule_changelist")
|
|
109
|
+
)
|
|
110
|
+
form = TaskiqTaskScheduleForm.from_scheduled_task(task, task_names=task_names)
|
|
111
|
+
return self._render_form(
|
|
112
|
+
request, form, title="Change taskiq schedule", object_id=object_id
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def external_delete_view(self, request, object_id):
|
|
116
|
+
if request.method == "POST":
|
|
117
|
+
source = _get_source(request)
|
|
118
|
+
async_to_sync(source.delete_schedule)(object_id)
|
|
119
|
+
messages.success(request, "Schedule deleted.")
|
|
120
|
+
return HttpResponseRedirect(
|
|
121
|
+
reverse("admin:taskiq_django_taskiqtaskschedule_changelist")
|
|
122
|
+
)
|
|
123
|
+
context = {
|
|
124
|
+
**self.admin_site.each_context(request),
|
|
125
|
+
"title": "Delete taskiq schedule",
|
|
126
|
+
"opts": self.model._meta,
|
|
127
|
+
"object_id": object_id,
|
|
128
|
+
}
|
|
129
|
+
return TemplateResponse(request, "taskiq_django/delete_confirmation.html", context)
|
|
130
|
+
|
|
131
|
+
def _render_form(self, request, form, *, title, object_id):
|
|
132
|
+
adminform = helpers.AdminForm(
|
|
133
|
+
form,
|
|
134
|
+
fieldsets=FIELDSETS,
|
|
135
|
+
prepopulated_fields={},
|
|
136
|
+
readonly_fields=[],
|
|
137
|
+
model_admin=self,
|
|
138
|
+
)
|
|
139
|
+
context = {
|
|
140
|
+
**self.admin_site.each_context(request),
|
|
141
|
+
"title": title,
|
|
142
|
+
"opts": self.model._meta,
|
|
143
|
+
"object_id": object_id,
|
|
144
|
+
"original": {"pk": object_id} if object_id else None,
|
|
145
|
+
"adminform": adminform,
|
|
146
|
+
"errors": helpers.AdminErrorList(form, []),
|
|
147
|
+
"media": self.media + adminform.media,
|
|
148
|
+
"is_popup": False,
|
|
149
|
+
"save_as": False,
|
|
150
|
+
"save_on_top": False,
|
|
151
|
+
"show_save": True,
|
|
152
|
+
"show_save_as_new": False,
|
|
153
|
+
"show_save_and_add_another": False,
|
|
154
|
+
"show_save_and_continue": False,
|
|
155
|
+
"show_close": False,
|
|
156
|
+
"show_delete_link": object_id is not None,
|
|
157
|
+
"can_change": True,
|
|
158
|
+
"add": object_id is None,
|
|
159
|
+
"change": object_id is not None,
|
|
160
|
+
"has_view_permission": True,
|
|
161
|
+
"has_add_permission": True,
|
|
162
|
+
"has_change_permission": True,
|
|
163
|
+
"has_delete_permission": True,
|
|
164
|
+
"has_file_field": False,
|
|
165
|
+
"has_editable_inline_admin_formsets": False,
|
|
166
|
+
"inline_admin_formsets": [],
|
|
167
|
+
}
|
|
168
|
+
return TemplateResponse(request, "taskiq_django/change_form.html", context)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _find_schedule(source: DjangoScheduleSource, schedule_id: str):
|
|
172
|
+
for task in async_to_sync(source.get_schedules)():
|
|
173
|
+
if task.schedule_id == schedule_id:
|
|
174
|
+
return task
|
|
175
|
+
return None
|
taskiq_django/apps.py
ADDED
taskiq_django/forms.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from django import forms
|
|
4
|
+
from django.contrib.admin import widgets as admin_widgets
|
|
5
|
+
from taskiq.scheduler.scheduled_task import ScheduledTask
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NonEmptyJSONField(forms.JSONField):
|
|
9
|
+
"""JSONField that treats {} and [] as valid (non-empty) values."""
|
|
10
|
+
|
|
11
|
+
empty_values = [None, ""]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskiqTaskScheduleForm(forms.Form):
|
|
15
|
+
schedule_id = forms.CharField(max_length=64, required=False, widget=forms.HiddenInput)
|
|
16
|
+
task_name = forms.CharField(
|
|
17
|
+
max_length=255,
|
|
18
|
+
widget=forms.TextInput(attrs={"class": "vTextField"}),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args, task_names: list[str] | None = None, **kwargs):
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
if task_names is not None:
|
|
24
|
+
choices = [(name, name) for name in task_names]
|
|
25
|
+
current = self.initial.get("task_name") or (
|
|
26
|
+
self.data.get("task_name") if self.is_bound else None
|
|
27
|
+
)
|
|
28
|
+
if current and current not in task_names:
|
|
29
|
+
choices.insert(0, (current, f"{current} (not registered)"))
|
|
30
|
+
self.fields["task_name"] = forms.ChoiceField(
|
|
31
|
+
choices=choices,
|
|
32
|
+
widget=forms.Select,
|
|
33
|
+
)
|
|
34
|
+
cron = forms.CharField(
|
|
35
|
+
max_length=255,
|
|
36
|
+
required=False,
|
|
37
|
+
widget=forms.TextInput(attrs={"class": "vTextField"}),
|
|
38
|
+
help_text="Standard cron expression (e.g. '0 * * * *').",
|
|
39
|
+
)
|
|
40
|
+
cron_offset = forms.CharField(
|
|
41
|
+
max_length=64,
|
|
42
|
+
required=False,
|
|
43
|
+
widget=forms.TextInput(attrs={"class": "vTextField"}),
|
|
44
|
+
help_text="Timezone or offset for cron evaluation.",
|
|
45
|
+
)
|
|
46
|
+
time = forms.SplitDateTimeField(
|
|
47
|
+
required=False,
|
|
48
|
+
widget=admin_widgets.AdminSplitDateTime,
|
|
49
|
+
help_text="One-shot run at this datetime (UTC).",
|
|
50
|
+
)
|
|
51
|
+
interval = forms.IntegerField(
|
|
52
|
+
min_value=1,
|
|
53
|
+
required=False,
|
|
54
|
+
widget=forms.NumberInput(attrs={"class": "vIntegerField"}),
|
|
55
|
+
help_text="Interval in seconds.",
|
|
56
|
+
)
|
|
57
|
+
args = NonEmptyJSONField(
|
|
58
|
+
initial=list,
|
|
59
|
+
widget=forms.Textarea(attrs={"class": "vLargeTextField", "rows": 4}),
|
|
60
|
+
)
|
|
61
|
+
kwargs = NonEmptyJSONField(
|
|
62
|
+
initial=dict,
|
|
63
|
+
widget=forms.Textarea(attrs={"class": "vLargeTextField", "rows": 4}),
|
|
64
|
+
)
|
|
65
|
+
labels = NonEmptyJSONField(
|
|
66
|
+
initial=dict,
|
|
67
|
+
widget=forms.Textarea(attrs={"class": "vLargeTextField", "rows": 4}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def clean(self):
|
|
71
|
+
cleaned = super().clean() or {}
|
|
72
|
+
if not any((cleaned.get("cron"), cleaned.get("time"), cleaned.get("interval"))):
|
|
73
|
+
raise forms.ValidationError("One of cron, time or interval must be set.")
|
|
74
|
+
return cleaned
|
|
75
|
+
|
|
76
|
+
def to_scheduled_task(self) -> ScheduledTask:
|
|
77
|
+
cleaned = self.cleaned_data
|
|
78
|
+
return ScheduledTask(
|
|
79
|
+
schedule_id=cleaned.get("schedule_id") or uuid.uuid4().hex,
|
|
80
|
+
task_name=cleaned["task_name"],
|
|
81
|
+
labels=cleaned.get("labels") or {},
|
|
82
|
+
args=cleaned.get("args") or [],
|
|
83
|
+
kwargs=cleaned.get("kwargs") or {},
|
|
84
|
+
cron=cleaned.get("cron") or None,
|
|
85
|
+
cron_offset=cleaned.get("cron_offset") or None,
|
|
86
|
+
time=cleaned.get("time"),
|
|
87
|
+
interval=cleaned.get("interval"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_scheduled_task(
|
|
92
|
+
cls,
|
|
93
|
+
task: ScheduledTask,
|
|
94
|
+
*,
|
|
95
|
+
task_names: list[str] | None = None,
|
|
96
|
+
) -> "TaskiqTaskScheduleForm":
|
|
97
|
+
return cls(
|
|
98
|
+
initial={
|
|
99
|
+
"schedule_id": task.schedule_id,
|
|
100
|
+
"task_name": task.task_name,
|
|
101
|
+
"cron": task.cron,
|
|
102
|
+
"cron_offset": task.cron_offset,
|
|
103
|
+
"time": task.time,
|
|
104
|
+
"interval": task.interval,
|
|
105
|
+
"args": task.args,
|
|
106
|
+
"kwargs": task.kwargs,
|
|
107
|
+
"labels": task.labels,
|
|
108
|
+
},
|
|
109
|
+
task_names=task_names,
|
|
110
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-06-09 23:51
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='TaskiqTaskSchedule',
|
|
16
|
+
fields=[
|
|
17
|
+
('schedule_id', models.CharField(max_length=64, primary_key=True, serialize=False)),
|
|
18
|
+
('task_name', models.CharField(max_length=255)),
|
|
19
|
+
('args', models.JSONField(db_default=[], default=list)),
|
|
20
|
+
('kwargs', models.JSONField(db_default={}, default=dict)),
|
|
21
|
+
('labels', models.JSONField(db_default={}, default=dict)),
|
|
22
|
+
('cron', models.CharField(blank=True, max_length=255, null=True)),
|
|
23
|
+
('cron_offset', models.CharField(blank=True, max_length=64, null=True)),
|
|
24
|
+
('time', models.DateTimeField(blank=True, null=True)),
|
|
25
|
+
('interval', models.PositiveIntegerField(blank=True, null=True)),
|
|
26
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
27
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
28
|
+
],
|
|
29
|
+
options={
|
|
30
|
+
'verbose_name': 'Task schedule',
|
|
31
|
+
'verbose_name_plural': 'Task schedules',
|
|
32
|
+
'db_table': 'taskiq_schedules',
|
|
33
|
+
},
|
|
34
|
+
),
|
|
35
|
+
]
|
|
File without changes
|
taskiq_django/models.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TaskiqTaskSchedule(models.Model):
|
|
5
|
+
schedule_id = models.CharField(max_length=64, primary_key=True)
|
|
6
|
+
task_name = models.CharField(max_length=255)
|
|
7
|
+
|
|
8
|
+
args = models.JSONField(default=list, db_default=list())
|
|
9
|
+
kwargs = models.JSONField(default=dict, db_default=dict())
|
|
10
|
+
labels = models.JSONField(default=dict, db_default=dict())
|
|
11
|
+
|
|
12
|
+
cron = models.CharField(max_length=255, null=True, blank=True)
|
|
13
|
+
cron_offset = models.CharField(max_length=64, null=True, blank=True)
|
|
14
|
+
time = models.DateTimeField(null=True, blank=True)
|
|
15
|
+
interval = models.PositiveIntegerField(null=True, blank=True)
|
|
16
|
+
|
|
17
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
18
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
19
|
+
|
|
20
|
+
class Meta:
|
|
21
|
+
verbose_name = "Task schedule"
|
|
22
|
+
verbose_name_plural = "Task schedules"
|
|
23
|
+
db_table = "taskiq_schedules"
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return f"{self.task_name} ({self.schedule_id})"
|
taskiq_django/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from taskiq import ScheduleSource
|
|
4
|
+
from taskiq.scheduler.scheduled_task import ScheduledTask
|
|
5
|
+
|
|
6
|
+
from taskiq_django.models import TaskiqTaskSchedule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _to_scheduled_task(row: TaskiqTaskSchedule) -> ScheduledTask:
|
|
10
|
+
return ScheduledTask(
|
|
11
|
+
schedule_id=row.schedule_id,
|
|
12
|
+
task_name=row.task_name,
|
|
13
|
+
labels=row.labels or {},
|
|
14
|
+
args=row.args or [],
|
|
15
|
+
kwargs=row.kwargs or {},
|
|
16
|
+
cron=row.cron,
|
|
17
|
+
cron_offset=row.cron_offset,
|
|
18
|
+
time=row.time,
|
|
19
|
+
interval=row.interval,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DjangoScheduleSource(ScheduleSource):
|
|
24
|
+
"""ScheduleSource backed by the Django ORM (TaskiqTaskSchedule model)."""
|
|
25
|
+
|
|
26
|
+
async def get_schedules(self) -> list[ScheduledTask]:
|
|
27
|
+
return [_to_scheduled_task(row) async for row in TaskiqTaskSchedule.objects.all()]
|
|
28
|
+
|
|
29
|
+
async def add_schedule(self, schedule: ScheduledTask) -> None:
|
|
30
|
+
interval = schedule.interval
|
|
31
|
+
if isinstance(interval, timedelta):
|
|
32
|
+
interval = int(interval.total_seconds())
|
|
33
|
+
|
|
34
|
+
cron_offset = schedule.cron_offset
|
|
35
|
+
if isinstance(cron_offset, timedelta):
|
|
36
|
+
cron_offset = str(int(cron_offset.total_seconds()))
|
|
37
|
+
|
|
38
|
+
await TaskiqTaskSchedule.objects.aupdate_or_create(
|
|
39
|
+
schedule_id=schedule.schedule_id,
|
|
40
|
+
defaults={
|
|
41
|
+
"task_name": schedule.task_name,
|
|
42
|
+
"labels": schedule.labels,
|
|
43
|
+
"args": schedule.args,
|
|
44
|
+
"kwargs": schedule.kwargs,
|
|
45
|
+
"cron": schedule.cron,
|
|
46
|
+
"cron_offset": cron_offset,
|
|
47
|
+
"time": schedule.time,
|
|
48
|
+
"interval": interval,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def delete_schedule(self, schedule_id: str) -> None:
|
|
53
|
+
await TaskiqTaskSchedule.objects.filter(schedule_id=schedule_id).adelete()
|
|
54
|
+
|
|
55
|
+
async def post_send(self, task: ScheduledTask) -> None:
|
|
56
|
+
if task.time is not None:
|
|
57
|
+
await self.delete_schedule(task.schedule_id)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{% extends "admin/change_form.html" %}
|
|
2
|
+
{% load i18n admin_urls %}
|
|
3
|
+
|
|
4
|
+
{% block object-tools %}{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block breadcrumbs %}
|
|
7
|
+
<div class="breadcrumbs">
|
|
8
|
+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
|
9
|
+
›
|
|
10
|
+
<a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
|
11
|
+
›
|
|
12
|
+
<a href="{% url 'admin:taskiq_django_taskiqtaskschedule_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
|
13
|
+
›
|
|
14
|
+
{{ title }}
|
|
15
|
+
</div>
|
|
16
|
+
{% endblock %}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{% extends "admin/base_site.html" %}
|
|
2
|
+
{% load i18n admin_urls %}
|
|
3
|
+
|
|
4
|
+
{% block breadcrumbs %}
|
|
5
|
+
<div class="breadcrumbs">
|
|
6
|
+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
|
7
|
+
›
|
|
8
|
+
<a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
|
9
|
+
›
|
|
10
|
+
<a href="{% url 'admin:taskiq_django_taskiqtaskschedule_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
|
11
|
+
›
|
|
12
|
+
{% translate 'Delete' %}
|
|
13
|
+
</div>
|
|
14
|
+
{% endblock %}
|
|
15
|
+
|
|
16
|
+
{% block content %}
|
|
17
|
+
<p>{% blocktranslate %}Are you sure you want to delete schedule <strong>{{ object_id }}</strong>?{% endblocktranslate %}</p>
|
|
18
|
+
<form method="post">
|
|
19
|
+
{% csrf_token %}
|
|
20
|
+
<input type="submit" value="{% translate "Yes, I'm sure" %}">
|
|
21
|
+
<a href="{% url 'admin:taskiq_django_taskiqtaskschedule_change' object_id %}" class="button cancel-link">
|
|
22
|
+
{% translate 'No, take me back' %}
|
|
23
|
+
</a>
|
|
24
|
+
</form>
|
|
25
|
+
{% endblock %}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% extends "admin/change_list.html" %}
|
|
2
|
+
{% load i18n admin_urls static %}
|
|
3
|
+
|
|
4
|
+
{% block breadcrumbs %}
|
|
5
|
+
<div class="breadcrumbs">
|
|
6
|
+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
|
7
|
+
›
|
|
8
|
+
<a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
|
9
|
+
›
|
|
10
|
+
{{ opts.verbose_name_plural|capfirst }}
|
|
11
|
+
</div>
|
|
12
|
+
{% endblock %}
|
|
13
|
+
|
|
14
|
+
{% block object-tools %}
|
|
15
|
+
<ul class="object-tools">
|
|
16
|
+
<li>
|
|
17
|
+
<a href="{% url 'admin:taskiq_django_taskiqtaskschedule_add' %}" class="addlink">Add taskiq schedule</a>
|
|
18
|
+
</li>
|
|
19
|
+
</ul>
|
|
20
|
+
{% endblock %}
|
|
21
|
+
|
|
22
|
+
{% block search %}{% endblock %}
|
|
23
|
+
{% block filters %}{% endblock %}
|
|
24
|
+
{% block pagination %}{% endblock %}
|
|
25
|
+
{% block date_hierarchy %}{% endblock %}
|
|
26
|
+
|
|
27
|
+
{% block result_list %}
|
|
28
|
+
<div class="results">
|
|
29
|
+
<table id="result_list">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr>
|
|
32
|
+
<th scope="col">
|
|
33
|
+
<div class="text">
|
|
34
|
+
<span>Task name</span>
|
|
35
|
+
</div>
|
|
36
|
+
</th>
|
|
37
|
+
<th scope="col">
|
|
38
|
+
<div class="text">
|
|
39
|
+
<span>Schedule</span>
|
|
40
|
+
</div>
|
|
41
|
+
</th>
|
|
42
|
+
<th scope="col">
|
|
43
|
+
<div class="text">
|
|
44
|
+
<span>Created at</span>
|
|
45
|
+
</div>
|
|
46
|
+
</th>
|
|
47
|
+
<th scope="col">
|
|
48
|
+
<div class="text">
|
|
49
|
+
<span>Updated at</span>
|
|
50
|
+
</div>
|
|
51
|
+
</th>
|
|
52
|
+
<th scope="col">
|
|
53
|
+
<div class="text">
|
|
54
|
+
<span></span>
|
|
55
|
+
</div>
|
|
56
|
+
</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{% for row in rows %}
|
|
61
|
+
<tr>
|
|
62
|
+
<th class="field-task_name">
|
|
63
|
+
<a href="{% url 'admin:taskiq_django_taskiqtaskschedule_change' row.id %}">
|
|
64
|
+
{{ row.task_name }}
|
|
65
|
+
</a>
|
|
66
|
+
</th>
|
|
67
|
+
<td>{{ row.schedule }}</td>
|
|
68
|
+
<td>{{ row.created_at }}</td>
|
|
69
|
+
<td>{{ row.updated_at }}</td>
|
|
70
|
+
<td>
|
|
71
|
+
<a href="{% url 'admin:taskiq_django_taskiqtaskschedule_delete' row.id %}"
|
|
72
|
+
class="deletelink">{% translate 'Delete' %}</a>
|
|
73
|
+
</td>
|
|
74
|
+
</tr>
|
|
75
|
+
{% empty %}
|
|
76
|
+
<tr>
|
|
77
|
+
<td colspan="5">No taskiq schedules available.</td>
|
|
78
|
+
</tr>
|
|
79
|
+
{% endfor %}
|
|
80
|
+
</tbody>
|
|
81
|
+
</table>
|
|
82
|
+
</div>
|
|
83
|
+
{% endblock %}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: taskiq-django
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Dist: django>=6.0.5
|
|
6
|
+
Requires-Dist: taskiq>=0.12.4
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# taskiq-django
|
|
11
|
+
|
|
12
|
+
Django integration for [Taskiq](https://taskiq-python.github.io/).
|
|
13
|
+
|
|
14
|
+
`taskiq-django` lets you run a Taskiq broker alongside a Django project and persist scheduled tasks in your Django database.
|
|
15
|
+
It ships with:
|
|
16
|
+
|
|
17
|
+
- `DjangoScheduleSource` — a `taskiq.ScheduleSource` backed by the Django ORM.
|
|
18
|
+
- A Django admin for adding, editing and deleting schedules through the web UI.
|
|
19
|
+
|
|
20
|
+
The package itself is broker-agnostic — pair it with any Taskiq broker (`AsyncpgBroker`, `RedisBroker`, `KafkaBroker`, etc.).
|
|
21
|
+
The `examples/` folder uses [taskiq-postgres](https://github.com/danfimov/taskiq-postgres/) on top of PostgreSQL.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install taskiq-django
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Add the app to `INSTALLED_APPS` so its model and admin are registered:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
# settings.py
|
|
33
|
+
INSTALLED_APPS = [
|
|
34
|
+
# ...
|
|
35
|
+
"taskiq_django",
|
|
36
|
+
]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then apply the migration that creates the `taskiq_schedules` table:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python manage.py migrate taskiq_django
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Using Taskiq with Django
|
|
46
|
+
|
|
47
|
+
Taskiq broker and scheduler are async, while Django historically is sync. The cleanest way to host both in a single process
|
|
48
|
+
is to serve Django over ASGI and let an ASGI server (`granian` or `uvicorn` for example) drive the broker lifecycle through
|
|
49
|
+
Starlette's `lifespan` hook.
|
|
50
|
+
|
|
51
|
+
The recipe below mirrors the one used in [`examples/example_app`](examples/example_app/) of this repository.
|
|
52
|
+
|
|
53
|
+
### Default Django application
|
|
54
|
+
|
|
55
|
+
A standard Django ASGI entry point looks like this:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# asgi.py
|
|
59
|
+
import os
|
|
60
|
+
from django.core.asgi import get_asgi_application
|
|
61
|
+
|
|
62
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_app.settings")
|
|
63
|
+
application = get_asgi_application()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This is enough to serve Django through any ASGI server, but it has no place to plug a Taskiq broker into — Django's ASGI app
|
|
67
|
+
does not expose `lifespan` events.
|
|
68
|
+
|
|
69
|
+
### Serving Django via Starlette
|
|
70
|
+
|
|
71
|
+
Wrap the Django ASGI app in a [Starlette](https://www.starlette.io/) application and mount it at `/`. Starlette supports
|
|
72
|
+
`lifespan`, makes static files easy, and lets us add ASGI middleware around Django:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# asgi.py
|
|
76
|
+
import os
|
|
77
|
+
from django.core.asgi import get_asgi_application
|
|
78
|
+
|
|
79
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_app.settings")
|
|
80
|
+
django_asgi = get_asgi_application()
|
|
81
|
+
|
|
82
|
+
from starlette.applications import Starlette
|
|
83
|
+
from starlette.routing import Mount
|
|
84
|
+
|
|
85
|
+
application = Starlette(
|
|
86
|
+
routes=(
|
|
87
|
+
Mount("/", django_asgi),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> Set `DJANGO_SETTINGS_MODULE` and call `get_asgi_application()` **before** importing anything that touches Django models
|
|
93
|
+
> (including `taskiq_django`). Otherwise `django.apps.AppRegistryNotReady` will fire at import time.
|
|
94
|
+
|
|
95
|
+
### Serving static files with Starlette
|
|
96
|
+
|
|
97
|
+
Collect Django's static files into a folder and serve it via `StaticFiles`. In your Django settings:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
# settings.py
|
|
101
|
+
STATIC_URL = "static/"
|
|
102
|
+
STATIC_ROOT = "static/"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then add a `Mount` in front of Django:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# asgi.py
|
|
109
|
+
from starlette.staticfiles import StaticFiles
|
|
110
|
+
|
|
111
|
+
application = Starlette(
|
|
112
|
+
routes=(
|
|
113
|
+
Mount("/static", StaticFiles(directory="static"), name="static"),
|
|
114
|
+
Mount("/", django_asgi),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run `python manage.py collectstatic` once so admin CSS/JS appears under `static/`.
|
|
120
|
+
|
|
121
|
+
### Broker and scheduler lifespan
|
|
122
|
+
|
|
123
|
+
Construct the broker and scheduler at module level, and use a Starlette `lifespan` to start and stop them with the process:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
# asgi.py
|
|
127
|
+
from contextlib import asynccontextmanager
|
|
128
|
+
from taskiq import TaskiqScheduler, async_shared_broker
|
|
129
|
+
from taskiq_pg.asyncpg import AsyncpgBroker
|
|
130
|
+
|
|
131
|
+
from taskiq_django import DjangoScheduleSource
|
|
132
|
+
|
|
133
|
+
DSN = "postgres://taskiq_django:look_in_vault@localhost:5432/taskiq_django"
|
|
134
|
+
broker = AsyncpgBroker(dsn=DSN)
|
|
135
|
+
async_shared_broker.default_broker(broker)
|
|
136
|
+
|
|
137
|
+
scheduler = TaskiqScheduler(
|
|
138
|
+
broker=broker,
|
|
139
|
+
sources=[DjangoScheduleSource()],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@asynccontextmanager
|
|
144
|
+
async def broker_lifespan(app):
|
|
145
|
+
await broker.startup()
|
|
146
|
+
for source in scheduler.sources:
|
|
147
|
+
await source.startup()
|
|
148
|
+
try:
|
|
149
|
+
yield
|
|
150
|
+
finally:
|
|
151
|
+
for source in scheduler.sources:
|
|
152
|
+
await source.shutdown()
|
|
153
|
+
await broker.shutdown()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
application = Starlette(
|
|
157
|
+
routes=(...),
|
|
158
|
+
lifespan=broker_lifespan,
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Injecting broker and scheduler into requests
|
|
163
|
+
|
|
164
|
+
If you want Django views (including the schedules admin) to reach the broker or the scheduler, add a small ASGI middleware
|
|
165
|
+
that puts them on the request scope. The Django `request.scope` dict is the same `scope` Starlette passed down, so values
|
|
166
|
+
land directly on `request.scope["broker"]` / `request.scope["scheduler"]`:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# asgi.py
|
|
170
|
+
from starlette.middleware import Middleware
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class InjectBrokerMiddleware:
|
|
174
|
+
def __init__(self, app):
|
|
175
|
+
self.app = app
|
|
176
|
+
|
|
177
|
+
async def __call__(self, scope, receive, send):
|
|
178
|
+
if scope["type"] in ("http", "websocket"):
|
|
179
|
+
scope["broker"] = broker
|
|
180
|
+
scope["scheduler"] = scheduler
|
|
181
|
+
await self.app(scope, receive, send)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
application = Starlette(
|
|
185
|
+
routes=(...),
|
|
186
|
+
lifespan=broker_lifespan,
|
|
187
|
+
middleware=[Middleware(InjectBrokerMiddleware)],
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Full example
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# asgi.py
|
|
195
|
+
import os
|
|
196
|
+
from contextlib import asynccontextmanager
|
|
197
|
+
|
|
198
|
+
from django.core.asgi import get_asgi_application
|
|
199
|
+
|
|
200
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_app.settings")
|
|
201
|
+
django_asgi = get_asgi_application()
|
|
202
|
+
|
|
203
|
+
from starlette.applications import Starlette
|
|
204
|
+
from starlette.middleware import Middleware
|
|
205
|
+
from starlette.routing import Mount
|
|
206
|
+
from starlette.staticfiles import StaticFiles
|
|
207
|
+
from taskiq import TaskiqScheduler, async_shared_broker
|
|
208
|
+
from taskiq_pg.asyncpg import AsyncpgBroker
|
|
209
|
+
|
|
210
|
+
from taskiq_django import DjangoScheduleSource
|
|
211
|
+
|
|
212
|
+
DSN = "postgres://taskiq_django:look_in_vault@localhost:5432/taskiq_django"
|
|
213
|
+
broker = AsyncpgBroker(dsn=DSN)
|
|
214
|
+
async_shared_broker.default_broker(broker)
|
|
215
|
+
|
|
216
|
+
scheduler = TaskiqScheduler(
|
|
217
|
+
broker=broker,
|
|
218
|
+
sources=[DjangoScheduleSource()],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@asynccontextmanager
|
|
223
|
+
async def broker_lifespan(app):
|
|
224
|
+
await broker.startup()
|
|
225
|
+
for source in scheduler.sources:
|
|
226
|
+
await source.startup()
|
|
227
|
+
try:
|
|
228
|
+
yield
|
|
229
|
+
finally:
|
|
230
|
+
for source in scheduler.sources:
|
|
231
|
+
await source.shutdown()
|
|
232
|
+
await broker.shutdown()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class InjectBrokerMiddleware:
|
|
236
|
+
def __init__(self, app):
|
|
237
|
+
self.app = app
|
|
238
|
+
|
|
239
|
+
async def __call__(self, scope, receive, send):
|
|
240
|
+
if scope["type"] in ("http", "websocket"):
|
|
241
|
+
scope["broker"] = broker
|
|
242
|
+
scope["scheduler"] = scheduler
|
|
243
|
+
await self.app(scope, receive, send)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
application = Starlette(
|
|
247
|
+
routes=(
|
|
248
|
+
Mount("/static", StaticFiles(directory="static"), name="static"),
|
|
249
|
+
Mount("/", django_asgi),
|
|
250
|
+
),
|
|
251
|
+
lifespan=broker_lifespan,
|
|
252
|
+
middleware=[Middleware(InjectBrokerMiddleware)],
|
|
253
|
+
)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Run the web process with any ASGI server, e.g. Granian:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
granian example_app.asgi:application --interface asgi --reload
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Defining tasks
|
|
263
|
+
|
|
264
|
+
Use `async_shared_broker.task` so tasks are not bound to a concrete broker at import time — the broker is attached at runtime
|
|
265
|
+
via `async_shared_broker.default_broker(broker)`:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
# example_app/tasks.py
|
|
269
|
+
from taskiq import async_shared_broker
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@async_shared_broker.task("solve_all_problems")
|
|
273
|
+
async def best_task_ever(message: str) -> None:
|
|
274
|
+
print(f'Got "{message}"')
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
To make sure tasks are discovered and registered with the broker, use Taskiq's filesystem discovery flag (`--fs-discover`)
|
|
278
|
+
when starting the worker, or import the tasks module from `asgi.py`.
|
|
279
|
+
|
|
280
|
+
## Persistent schedules with `DjangoScheduleSource`
|
|
281
|
+
|
|
282
|
+
`DjangoScheduleSource` stores `ScheduledTask` records in the Django database via the `taskiq_schedules` table. Plug it into
|
|
283
|
+
your scheduler:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
from taskiq import TaskiqScheduler
|
|
287
|
+
from taskiq_django import DjangoScheduleSource
|
|
288
|
+
|
|
289
|
+
scheduler = TaskiqScheduler(
|
|
290
|
+
broker=broker,
|
|
291
|
+
sources=[DjangoScheduleSource()],
|
|
292
|
+
)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Schedules can now be managed through the Django admin: `/admin/taskiq_django/taskiqtaskschedule/` exposes list / add /
|
|
296
|
+
change / delete views. Each action routes through the source's `add_schedule` / `delete_schedule` methods, so any side
|
|
297
|
+
effects you add by subclassing `DjangoScheduleSource` will fire from the admin too.
|
|
298
|
+
|
|
299
|
+
A schedule row carries the full `ScheduledTask` payload — `task_name`, `args`, `kwargs`, `labels`, plus the schedule
|
|
300
|
+
definition (`cron` + optional `cron_offset`, or `time` for one-shots, or `interval` in seconds). Exactly one of `cron` /
|
|
301
|
+
`time` / `interval` must be set.
|
|
302
|
+
|
|
303
|
+
> `DjangoScheduleSource.startup()` does **not** truncate or seed the table — the admin is treated as the source of truth.
|
|
304
|
+
> If you want to sync schedules declared on `@task(schedule=[...])` labels into the database, do it explicitly from a
|
|
305
|
+
> one-off script or a management command.
|
|
306
|
+
|
|
307
|
+
## Running the worker and the scheduler
|
|
308
|
+
|
|
309
|
+
Both the worker and the scheduler import the broker and the scheduler objects from `asgi.py`:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
# worker
|
|
313
|
+
taskiq worker example_app.asgi:broker --fs-discover
|
|
314
|
+
|
|
315
|
+
# scheduler
|
|
316
|
+
taskiq scheduler example_app.asgi:scheduler --fs-discover
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Because `asgi.py` calls `get_asgi_application()` at import time, Django is fully configured by the time the worker or
|
|
320
|
+
scheduler process touches any ORM code — no extra bootstrapping needed.
|
|
321
|
+
|
|
322
|
+
If you'd rather keep `asgi.py` web-only, move the broker/scheduler/`DJANGO_SETTINGS_MODULE` setup into a dedicated
|
|
323
|
+
`broker.py` module and call `django.setup()` at the top of it; then point Taskiq at `example_app.broker:broker` instead.
|
|
324
|
+
|
|
325
|
+
## Accessing the Django ORM from a Taskiq task
|
|
326
|
+
|
|
327
|
+
Inside a worker process, Django ORM is fully available — both sync and async APIs:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
from django.contrib.auth.models import User
|
|
331
|
+
from taskiq import async_shared_broker
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@async_shared_broker.task("list_users")
|
|
335
|
+
async def list_users() -> None:
|
|
336
|
+
async for user in User.objects.all():
|
|
337
|
+
print(user.username)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
When the worker imports `example_app.asgi:broker`, the module-level `get_asgi_application()` call has already run
|
|
341
|
+
`django.setup()`, so model imports work immediately.
|
|
342
|
+
|
|
343
|
+
## Local development
|
|
344
|
+
|
|
345
|
+
The example project under `examples/` ships with a `Makefile`:
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
make run_infra # docker compose up -d postgres
|
|
349
|
+
make run # granian + Starlette + Django
|
|
350
|
+
make run_worker # taskiq worker
|
|
351
|
+
make run_scheduler # taskiq scheduler
|
|
352
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
taskiq_django/__init__.py,sha256=1-Yjs7wuAdCB8TbiRlBzD-5QJTuemoP-Nd3yHatkEFQ,290
|
|
2
|
+
taskiq_django/admin.py,sha256=xypMeX4ET-S8Y1yLS9dajuGIALGJCUoiuCddjNmtd7k,7050
|
|
3
|
+
taskiq_django/apps.py,sha256=CuXdfHkb26FP0qqdSug5-9RWGAzLqtxZcvGiJchXPxo,150
|
|
4
|
+
taskiq_django/forms.py,sha256=7bkJjVXd3XLA2rgNoDgGKMfgQrM5XPqeZY-CFa12s4w,3879
|
|
5
|
+
taskiq_django/migrations/0001_initial.py,sha256=9jOOQ_nyHOFEpNTegiQu45cIxybbMxqDdAFpvJLXOXk,1370
|
|
6
|
+
taskiq_django/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
taskiq_django/models.py,sha256=6BiYKjhXU-9nJHFTkwl3sL4wgosflCO3wMQWIRnbIWY,977
|
|
8
|
+
taskiq_django/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
taskiq_django/schedule_source.py,sha256=qeqfgxuW9ez0TVaseF5yuRd45JGqQ-nojUb3xOhmFak,1975
|
|
10
|
+
taskiq_django/templates/taskiq_django/change_form.html,sha256=gVejj_-FrIvOU0XTBbh6uns7zEh6iD6dapR9ckyOpGM,510
|
|
11
|
+
taskiq_django/templates/taskiq_django/delete_confirmation.html,sha256=FgO7SsAQ43b-19n4VoamOSJdp1S6_TGHgXh3LVmm5jc,908
|
|
12
|
+
taskiq_django/templates/taskiq_django/list_view.html,sha256=uj8SkMhA5MjZ-3FBjbmCE_s4IU6DqOGOGgf66L1Xb60,2131
|
|
13
|
+
taskiq_django-0.0.1.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
|
|
14
|
+
taskiq_django-0.0.1.dist-info/METADATA,sha256=Je2ZqOgupN7M7UZ61fVsXQYXx18s7rqaqzxq6tZ_TgA,10492
|
|
15
|
+
taskiq_django-0.0.1.dist-info/RECORD,,
|