taskiq-django 0.0.1__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.
@@ -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,343 @@
1
+ # taskiq-django
2
+
3
+ Django integration for [Taskiq](https://taskiq-python.github.io/).
4
+
5
+ `taskiq-django` lets you run a Taskiq broker alongside a Django project and persist scheduled tasks in your Django database.
6
+ It ships with:
7
+
8
+ - `DjangoScheduleSource` — a `taskiq.ScheduleSource` backed by the Django ORM.
9
+ - A Django admin for adding, editing and deleting schedules through the web UI.
10
+
11
+ The package itself is broker-agnostic — pair it with any Taskiq broker (`AsyncpgBroker`, `RedisBroker`, `KafkaBroker`, etc.).
12
+ The `examples/` folder uses [taskiq-postgres](https://github.com/danfimov/taskiq-postgres/) on top of PostgreSQL.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install taskiq-django
18
+ ```
19
+
20
+ Add the app to `INSTALLED_APPS` so its model and admin are registered:
21
+
22
+ ```python
23
+ # settings.py
24
+ INSTALLED_APPS = [
25
+ # ...
26
+ "taskiq_django",
27
+ ]
28
+ ```
29
+
30
+ Then apply the migration that creates the `taskiq_schedules` table:
31
+
32
+ ```bash
33
+ python manage.py migrate taskiq_django
34
+ ```
35
+
36
+ ## Using Taskiq with Django
37
+
38
+ Taskiq broker and scheduler are async, while Django historically is sync. The cleanest way to host both in a single process
39
+ is to serve Django over ASGI and let an ASGI server (`granian` or `uvicorn` for example) drive the broker lifecycle through
40
+ Starlette's `lifespan` hook.
41
+
42
+ The recipe below mirrors the one used in [`examples/example_app`](examples/example_app/) of this repository.
43
+
44
+ ### Default Django application
45
+
46
+ A standard Django ASGI entry point looks like this:
47
+
48
+ ```python
49
+ # asgi.py
50
+ import os
51
+ from django.core.asgi import get_asgi_application
52
+
53
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_app.settings")
54
+ application = get_asgi_application()
55
+ ```
56
+
57
+ 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
58
+ does not expose `lifespan` events.
59
+
60
+ ### Serving Django via Starlette
61
+
62
+ Wrap the Django ASGI app in a [Starlette](https://www.starlette.io/) application and mount it at `/`. Starlette supports
63
+ `lifespan`, makes static files easy, and lets us add ASGI middleware around Django:
64
+
65
+ ```python
66
+ # asgi.py
67
+ import os
68
+ from django.core.asgi import get_asgi_application
69
+
70
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_app.settings")
71
+ django_asgi = get_asgi_application()
72
+
73
+ from starlette.applications import Starlette
74
+ from starlette.routing import Mount
75
+
76
+ application = Starlette(
77
+ routes=(
78
+ Mount("/", django_asgi),
79
+ ),
80
+ )
81
+ ```
82
+
83
+ > Set `DJANGO_SETTINGS_MODULE` and call `get_asgi_application()` **before** importing anything that touches Django models
84
+ > (including `taskiq_django`). Otherwise `django.apps.AppRegistryNotReady` will fire at import time.
85
+
86
+ ### Serving static files with Starlette
87
+
88
+ Collect Django's static files into a folder and serve it via `StaticFiles`. In your Django settings:
89
+
90
+ ```python
91
+ # settings.py
92
+ STATIC_URL = "static/"
93
+ STATIC_ROOT = "static/"
94
+ ```
95
+
96
+ Then add a `Mount` in front of Django:
97
+
98
+ ```python
99
+ # asgi.py
100
+ from starlette.staticfiles import StaticFiles
101
+
102
+ application = Starlette(
103
+ routes=(
104
+ Mount("/static", StaticFiles(directory="static"), name="static"),
105
+ Mount("/", django_asgi),
106
+ ),
107
+ )
108
+ ```
109
+
110
+ Run `python manage.py collectstatic` once so admin CSS/JS appears under `static/`.
111
+
112
+ ### Broker and scheduler lifespan
113
+
114
+ Construct the broker and scheduler at module level, and use a Starlette `lifespan` to start and stop them with the process:
115
+
116
+ ```python
117
+ # asgi.py
118
+ from contextlib import asynccontextmanager
119
+ from taskiq import TaskiqScheduler, async_shared_broker
120
+ from taskiq_pg.asyncpg import AsyncpgBroker
121
+
122
+ from taskiq_django import DjangoScheduleSource
123
+
124
+ DSN = "postgres://taskiq_django:look_in_vault@localhost:5432/taskiq_django"
125
+ broker = AsyncpgBroker(dsn=DSN)
126
+ async_shared_broker.default_broker(broker)
127
+
128
+ scheduler = TaskiqScheduler(
129
+ broker=broker,
130
+ sources=[DjangoScheduleSource()],
131
+ )
132
+
133
+
134
+ @asynccontextmanager
135
+ async def broker_lifespan(app):
136
+ await broker.startup()
137
+ for source in scheduler.sources:
138
+ await source.startup()
139
+ try:
140
+ yield
141
+ finally:
142
+ for source in scheduler.sources:
143
+ await source.shutdown()
144
+ await broker.shutdown()
145
+
146
+
147
+ application = Starlette(
148
+ routes=(...),
149
+ lifespan=broker_lifespan,
150
+ )
151
+ ```
152
+
153
+ ### Injecting broker and scheduler into requests
154
+
155
+ If you want Django views (including the schedules admin) to reach the broker or the scheduler, add a small ASGI middleware
156
+ that puts them on the request scope. The Django `request.scope` dict is the same `scope` Starlette passed down, so values
157
+ land directly on `request.scope["broker"]` / `request.scope["scheduler"]`:
158
+
159
+ ```python
160
+ # asgi.py
161
+ from starlette.middleware import Middleware
162
+
163
+
164
+ class InjectBrokerMiddleware:
165
+ def __init__(self, app):
166
+ self.app = app
167
+
168
+ async def __call__(self, scope, receive, send):
169
+ if scope["type"] in ("http", "websocket"):
170
+ scope["broker"] = broker
171
+ scope["scheduler"] = scheduler
172
+ await self.app(scope, receive, send)
173
+
174
+
175
+ application = Starlette(
176
+ routes=(...),
177
+ lifespan=broker_lifespan,
178
+ middleware=[Middleware(InjectBrokerMiddleware)],
179
+ )
180
+ ```
181
+
182
+ ### Full example
183
+
184
+ ```python
185
+ # asgi.py
186
+ import os
187
+ from contextlib import asynccontextmanager
188
+
189
+ from django.core.asgi import get_asgi_application
190
+
191
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_app.settings")
192
+ django_asgi = get_asgi_application()
193
+
194
+ from starlette.applications import Starlette
195
+ from starlette.middleware import Middleware
196
+ from starlette.routing import Mount
197
+ from starlette.staticfiles import StaticFiles
198
+ from taskiq import TaskiqScheduler, async_shared_broker
199
+ from taskiq_pg.asyncpg import AsyncpgBroker
200
+
201
+ from taskiq_django import DjangoScheduleSource
202
+
203
+ DSN = "postgres://taskiq_django:look_in_vault@localhost:5432/taskiq_django"
204
+ broker = AsyncpgBroker(dsn=DSN)
205
+ async_shared_broker.default_broker(broker)
206
+
207
+ scheduler = TaskiqScheduler(
208
+ broker=broker,
209
+ sources=[DjangoScheduleSource()],
210
+ )
211
+
212
+
213
+ @asynccontextmanager
214
+ async def broker_lifespan(app):
215
+ await broker.startup()
216
+ for source in scheduler.sources:
217
+ await source.startup()
218
+ try:
219
+ yield
220
+ finally:
221
+ for source in scheduler.sources:
222
+ await source.shutdown()
223
+ await broker.shutdown()
224
+
225
+
226
+ class InjectBrokerMiddleware:
227
+ def __init__(self, app):
228
+ self.app = app
229
+
230
+ async def __call__(self, scope, receive, send):
231
+ if scope["type"] in ("http", "websocket"):
232
+ scope["broker"] = broker
233
+ scope["scheduler"] = scheduler
234
+ await self.app(scope, receive, send)
235
+
236
+
237
+ application = Starlette(
238
+ routes=(
239
+ Mount("/static", StaticFiles(directory="static"), name="static"),
240
+ Mount("/", django_asgi),
241
+ ),
242
+ lifespan=broker_lifespan,
243
+ middleware=[Middleware(InjectBrokerMiddleware)],
244
+ )
245
+ ```
246
+
247
+ Run the web process with any ASGI server, e.g. Granian:
248
+
249
+ ```bash
250
+ granian example_app.asgi:application --interface asgi --reload
251
+ ```
252
+
253
+ ## Defining tasks
254
+
255
+ Use `async_shared_broker.task` so tasks are not bound to a concrete broker at import time — the broker is attached at runtime
256
+ via `async_shared_broker.default_broker(broker)`:
257
+
258
+ ```python
259
+ # example_app/tasks.py
260
+ from taskiq import async_shared_broker
261
+
262
+
263
+ @async_shared_broker.task("solve_all_problems")
264
+ async def best_task_ever(message: str) -> None:
265
+ print(f'Got "{message}"')
266
+ ```
267
+
268
+ To make sure tasks are discovered and registered with the broker, use Taskiq's filesystem discovery flag (`--fs-discover`)
269
+ when starting the worker, or import the tasks module from `asgi.py`.
270
+
271
+ ## Persistent schedules with `DjangoScheduleSource`
272
+
273
+ `DjangoScheduleSource` stores `ScheduledTask` records in the Django database via the `taskiq_schedules` table. Plug it into
274
+ your scheduler:
275
+
276
+ ```python
277
+ from taskiq import TaskiqScheduler
278
+ from taskiq_django import DjangoScheduleSource
279
+
280
+ scheduler = TaskiqScheduler(
281
+ broker=broker,
282
+ sources=[DjangoScheduleSource()],
283
+ )
284
+ ```
285
+
286
+ Schedules can now be managed through the Django admin: `/admin/taskiq_django/taskiqtaskschedule/` exposes list / add /
287
+ change / delete views. Each action routes through the source's `add_schedule` / `delete_schedule` methods, so any side
288
+ effects you add by subclassing `DjangoScheduleSource` will fire from the admin too.
289
+
290
+ A schedule row carries the full `ScheduledTask` payload — `task_name`, `args`, `kwargs`, `labels`, plus the schedule
291
+ definition (`cron` + optional `cron_offset`, or `time` for one-shots, or `interval` in seconds). Exactly one of `cron` /
292
+ `time` / `interval` must be set.
293
+
294
+ > `DjangoScheduleSource.startup()` does **not** truncate or seed the table — the admin is treated as the source of truth.
295
+ > If you want to sync schedules declared on `@task(schedule=[...])` labels into the database, do it explicitly from a
296
+ > one-off script or a management command.
297
+
298
+ ## Running the worker and the scheduler
299
+
300
+ Both the worker and the scheduler import the broker and the scheduler objects from `asgi.py`:
301
+
302
+ ```bash
303
+ # worker
304
+ taskiq worker example_app.asgi:broker --fs-discover
305
+
306
+ # scheduler
307
+ taskiq scheduler example_app.asgi:scheduler --fs-discover
308
+ ```
309
+
310
+ Because `asgi.py` calls `get_asgi_application()` at import time, Django is fully configured by the time the worker or
311
+ scheduler process touches any ORM code — no extra bootstrapping needed.
312
+
313
+ If you'd rather keep `asgi.py` web-only, move the broker/scheduler/`DJANGO_SETTINGS_MODULE` setup into a dedicated
314
+ `broker.py` module and call `django.setup()` at the top of it; then point Taskiq at `example_app.broker:broker` instead.
315
+
316
+ ## Accessing the Django ORM from a Taskiq task
317
+
318
+ Inside a worker process, Django ORM is fully available — both sync and async APIs:
319
+
320
+ ```python
321
+ from django.contrib.auth.models import User
322
+ from taskiq import async_shared_broker
323
+
324
+
325
+ @async_shared_broker.task("list_users")
326
+ async def list_users() -> None:
327
+ async for user in User.objects.all():
328
+ print(user.username)
329
+ ```
330
+
331
+ When the worker imports `example_app.asgi:broker`, the module-level `get_asgi_application()` call has already run
332
+ `django.setup()`, so model imports work immediately.
333
+
334
+ ## Local development
335
+
336
+ The example project under `examples/` ships with a `Makefile`:
337
+
338
+ ```bash
339
+ make run_infra # docker compose up -d postgres
340
+ make run # granian + Starlette + Django
341
+ make run_worker # taskiq worker
342
+ make run_scheduler # taskiq scheduler
343
+ ```
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "taskiq-django"
3
+ version = "0.0.1"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "django>=6.0.5",
9
+ "taskiq>=0.12.4",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "prek>=0.4.4",
15
+ {include-group = "test"},
16
+ {include-group = "lint"},
17
+ {include-group = "types"},
18
+ {include-group = "examples"},
19
+ ]
20
+ lint = [
21
+ "ruff>=0.15.14",
22
+ "zizmor>=1.25.2",
23
+ ]
24
+ types = [
25
+ "django-stubs>=6.0.5",
26
+ "ty>=0.0.39",
27
+ ]
28
+ examples = [
29
+ "granian[reload]>=2.7.5",
30
+ "starlette>=1.2.1",
31
+ "taskiq-postgres[asyncpg]>=0.8.0",
32
+ ]
33
+ test = [
34
+ "pytest>=9.1.0",
35
+ "pytest-asyncio>=1.4.0",
36
+ "pytest-django>=4.12.0",
37
+ "dirty-equals>=0.11",
38
+ "pytest-deadfixtures>=3.1.0",
39
+ ]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.11,<0.12"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.pytest.ini_options]
46
+ DJANGO_SETTINGS_MODULE = "tests.settings"
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
49
+ pythonpath = ["."]
50
+
51
+ [tool.ruff]
52
+ line-length = 120
53
+
54
+ [tool.ruff.lint]
55
+ select = [
56
+ "I",
57
+ "E",
58
+ "W",
59
+ "S",
60
+ "F",
61
+ ]
62
+
63
+ [tool.ruff.lint.per-file-ignores]
64
+ "examples/example_app/asgi.py" = ["E402"]
65
+ "tests/**" = ["S101"]
@@ -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}")
@@ -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
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class TaskiqApplicationConfig(AppConfig):
5
+ name = "taskiq_django"
6
+ verbose_name = "Taskiq django extension"
@@ -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
+ ]
@@ -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})"
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
+ &rsaquo;
10
+ <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
11
+ &rsaquo;
12
+ <a href="{% url 'admin:taskiq_django_taskiqtaskschedule_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
13
+ &rsaquo;
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
+ &rsaquo;
8
+ <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
9
+ &rsaquo;
10
+ <a href="{% url 'admin:taskiq_django_taskiqtaskschedule_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
11
+ &rsaquo;
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
+ &rsaquo;
8
+ <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
9
+ &rsaquo;
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 %}