django-ray 0.1.0__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.
- django_ray/__init__.py +6 -0
- django_ray/admin.py +461 -0
- django_ray/apps.py +23 -0
- django_ray/backends.py +313 -0
- django_ray/conf/__init__.py +10 -0
- django_ray/conf/defaults.py +27 -0
- django_ray/conf/settings.py +67 -0
- django_ray/logging.py +148 -0
- django_ray/management/__init__.py +1 -0
- django_ray/management/commands/__init__.py +1 -0
- django_ray/management/commands/django_ray_worker.py +1186 -0
- django_ray/migrations/0001_initial.py +214 -0
- django_ray/migrations/__init__.py +1 -0
- django_ray/models.py +217 -0
- django_ray/runner/__init__.py +14 -0
- django_ray/runner/base.py +100 -0
- django_ray/runner/cancellation.py +64 -0
- django_ray/runner/leasing.py +155 -0
- django_ray/runner/ray_core.py +324 -0
- django_ray/runner/ray_job.py +117 -0
- django_ray/runner/reconciliation.py +101 -0
- django_ray/runner/retry.py +92 -0
- django_ray/runtime/__init__.py +16 -0
- django_ray/runtime/distributed.py +300 -0
- django_ray/runtime/entrypoint.py +85 -0
- django_ray/runtime/import_utils.py +51 -0
- django_ray/runtime/serialization.py +46 -0
- django_ray-0.1.0.dist-info/METADATA +303 -0
- django_ray-0.1.0.dist-info/RECORD +31 -0
- django_ray-0.1.0.dist-info/WHEEL +4 -0
- django_ray-0.1.0.dist-info/licenses/LICENSE +29 -0
django_ray/__init__.py
ADDED
django_ray/admin.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Django admin configuration for django-ray."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
from django.db.models import QuerySet
|
|
7
|
+
from django.http import HttpRequest
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
from django.utils.safestring import mark_safe
|
|
10
|
+
|
|
11
|
+
from django_ray.models import RayTaskExecution, TaskState, TaskWorkerLease
|
|
12
|
+
|
|
13
|
+
# Ray Dashboard URL - can be overridden via Django settings
|
|
14
|
+
RAY_DASHBOARD_URL = "http://localhost:30265"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@admin.register(RayTaskExecution)
|
|
18
|
+
class RayTaskExecutionAdmin(admin.ModelAdmin):
|
|
19
|
+
"""Admin for RayTaskExecution model."""
|
|
20
|
+
|
|
21
|
+
list_display = [
|
|
22
|
+
"id",
|
|
23
|
+
"callable_path",
|
|
24
|
+
"state_display",
|
|
25
|
+
"queue_name",
|
|
26
|
+
"attempt_number",
|
|
27
|
+
"ray_dashboard_link",
|
|
28
|
+
"created_at",
|
|
29
|
+
"started_at",
|
|
30
|
+
"finished_at",
|
|
31
|
+
]
|
|
32
|
+
list_filter = [
|
|
33
|
+
"state",
|
|
34
|
+
"queue_name",
|
|
35
|
+
"created_at",
|
|
36
|
+
]
|
|
37
|
+
search_fields = [
|
|
38
|
+
"task_id",
|
|
39
|
+
"callable_path",
|
|
40
|
+
"ray_job_id",
|
|
41
|
+
]
|
|
42
|
+
readonly_fields = [
|
|
43
|
+
"task_id",
|
|
44
|
+
"callable_path",
|
|
45
|
+
"ray_job_id_display",
|
|
46
|
+
"ray_address",
|
|
47
|
+
"created_at",
|
|
48
|
+
"started_at",
|
|
49
|
+
"finished_at",
|
|
50
|
+
"last_heartbeat_at",
|
|
51
|
+
"args_json",
|
|
52
|
+
"kwargs_json",
|
|
53
|
+
"result_data",
|
|
54
|
+
"error_message",
|
|
55
|
+
"error_traceback",
|
|
56
|
+
]
|
|
57
|
+
fieldsets = (
|
|
58
|
+
(
|
|
59
|
+
"Task Info",
|
|
60
|
+
{
|
|
61
|
+
"fields": ("task_id", "callable_path", "queue_name", "state", "attempt_number"),
|
|
62
|
+
},
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"Arguments",
|
|
66
|
+
{
|
|
67
|
+
"fields": ("args_json", "kwargs_json"),
|
|
68
|
+
"classes": ("collapse",),
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
(
|
|
72
|
+
"Result",
|
|
73
|
+
{
|
|
74
|
+
"fields": ("result_data", "error_message", "error_traceback"),
|
|
75
|
+
},
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
"Ray Execution",
|
|
79
|
+
{
|
|
80
|
+
"fields": ("ray_job_id_display", "ray_address", "claimed_by_worker"),
|
|
81
|
+
"description": "Ray Job ID is only available for Ray Job API mode.",
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"Timing",
|
|
86
|
+
{
|
|
87
|
+
"fields": ("created_at", "started_at", "finished_at", "last_heartbeat_at"),
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
ordering = ["-created_at"]
|
|
92
|
+
actions = ["retry_tasks", "cancel_tasks"]
|
|
93
|
+
|
|
94
|
+
@admin.display(description="Ray Job ID")
|
|
95
|
+
def ray_job_id_display(self, obj: RayTaskExecution) -> str:
|
|
96
|
+
"""Display Ray Job ID with link to Ray Dashboard."""
|
|
97
|
+
from django.conf import settings
|
|
98
|
+
|
|
99
|
+
ray_job_id = obj.ray_job_id
|
|
100
|
+
if not ray_job_id:
|
|
101
|
+
return "Not yet submitted"
|
|
102
|
+
|
|
103
|
+
job_id = str(ray_job_id)
|
|
104
|
+
dashboard_url = getattr(settings, "RAY_DASHBOARD_URL", RAY_DASHBOARD_URL)
|
|
105
|
+
|
|
106
|
+
# Old format: ray_core:pk
|
|
107
|
+
if job_id.startswith("ray_core:"):
|
|
108
|
+
return "N/A (legacy format)"
|
|
109
|
+
|
|
110
|
+
# New format: job_id:task_id (e.g., "02000000:67a2e8cfa5...")
|
|
111
|
+
if ":" in job_id and not job_id.startswith("raysubmit_"):
|
|
112
|
+
parts = job_id.split(":", 1)
|
|
113
|
+
ray_job = parts[0]
|
|
114
|
+
ray_task = parts[1]
|
|
115
|
+
url = f"{dashboard_url}/#/jobs/{ray_job}/tasks/{ray_task}"
|
|
116
|
+
return mark_safe(
|
|
117
|
+
f"Job: {ray_job}, Task: {ray_task[:16]}... "
|
|
118
|
+
f'<a href="{url}" target="_blank">[Open in Dashboard]</a>'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Ray Job API format
|
|
122
|
+
url = f"{dashboard_url}/#/jobs/{job_id}"
|
|
123
|
+
return mark_safe(f'{job_id} <a href="{url}" target="_blank">[Open in Dashboard]</a>')
|
|
124
|
+
|
|
125
|
+
@admin.display(description="State", ordering="state")
|
|
126
|
+
def state_display(self, obj: RayTaskExecution) -> str:
|
|
127
|
+
"""Display state with color coding."""
|
|
128
|
+
colors: dict[str, str] = {
|
|
129
|
+
TaskState.QUEUED: "#6c757d",
|
|
130
|
+
TaskState.RUNNING: "#007bff",
|
|
131
|
+
TaskState.SUCCEEDED: "#28a745",
|
|
132
|
+
TaskState.FAILED: "#dc3545",
|
|
133
|
+
TaskState.CANCELLED: "#ffc107",
|
|
134
|
+
TaskState.CANCELLING: "#ffc107",
|
|
135
|
+
TaskState.LOST: "#dc3545",
|
|
136
|
+
}
|
|
137
|
+
state = str(obj.state)
|
|
138
|
+
color = colors.get(state, "#6c757d")
|
|
139
|
+
# state is from TaskState constants, not user input, so mark_safe is appropriate
|
|
140
|
+
return mark_safe(f'<span style="color: {color}; font-weight: bold;">{state}</span>')
|
|
141
|
+
|
|
142
|
+
@admin.display(description="Ray Dashboard")
|
|
143
|
+
def ray_dashboard_link(self, obj: RayTaskExecution) -> str:
|
|
144
|
+
"""Display link to Ray Dashboard for the job/task."""
|
|
145
|
+
from django.conf import settings
|
|
146
|
+
|
|
147
|
+
ray_job_id = obj.ray_job_id
|
|
148
|
+
if not ray_job_id:
|
|
149
|
+
return "-"
|
|
150
|
+
|
|
151
|
+
job_id = str(ray_job_id)
|
|
152
|
+
|
|
153
|
+
# Get dashboard URL from settings or use default
|
|
154
|
+
dashboard_url = getattr(settings, "RAY_DASHBOARD_URL", RAY_DASHBOARD_URL)
|
|
155
|
+
|
|
156
|
+
# Old format: ray_core:pk - no useful link
|
|
157
|
+
if job_id.startswith("ray_core:"):
|
|
158
|
+
url = f"{dashboard_url}/#/jobs"
|
|
159
|
+
return mark_safe(f'<a href="{url}" target="_blank">Jobs</a>')
|
|
160
|
+
|
|
161
|
+
# New format: job_id:task_id (e.g., "02000000:67a2e8cfa5...")
|
|
162
|
+
if ":" in job_id and not job_id.startswith("raysubmit_"):
|
|
163
|
+
parts = job_id.split(":", 1)
|
|
164
|
+
ray_job = parts[0]
|
|
165
|
+
ray_task = parts[1]
|
|
166
|
+
# Link directly to the task in the Ray Dashboard
|
|
167
|
+
url = f"{dashboard_url}/#/jobs/{ray_job}/tasks/{ray_task}"
|
|
168
|
+
return mark_safe(f'<a href="{url}" target="_blank">Task</a>')
|
|
169
|
+
|
|
170
|
+
# Ray Job API - link to the job
|
|
171
|
+
url = f"{dashboard_url}/#/jobs/{job_id}"
|
|
172
|
+
return mark_safe(f'<a href="{url}" target="_blank">{job_id[:8]}...</a>')
|
|
173
|
+
|
|
174
|
+
@admin.action(description="Retry selected tasks")
|
|
175
|
+
def retry_tasks(self, request: HttpRequest, queryset: QuerySet[RayTaskExecution]) -> None:
|
|
176
|
+
"""Retry failed or lost tasks by resetting them to QUEUED state."""
|
|
177
|
+
retryable_states = [TaskState.FAILED, TaskState.LOST]
|
|
178
|
+
tasks_to_retry = queryset.filter(state__in=retryable_states)
|
|
179
|
+
count = tasks_to_retry.count()
|
|
180
|
+
|
|
181
|
+
if count == 0:
|
|
182
|
+
self.message_user(
|
|
183
|
+
request,
|
|
184
|
+
"No failed or lost tasks found in selection.",
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Reset tasks to QUEUED state for retry
|
|
189
|
+
# TODO: In the future, we could preserve attempt history in a separate model
|
|
190
|
+
# to track each run's result/error instead of overwriting
|
|
191
|
+
tasks_to_retry.update(
|
|
192
|
+
state=TaskState.QUEUED,
|
|
193
|
+
attempt_number=0,
|
|
194
|
+
result_data=None,
|
|
195
|
+
error_message=None,
|
|
196
|
+
error_traceback=None,
|
|
197
|
+
started_at=None,
|
|
198
|
+
finished_at=None,
|
|
199
|
+
last_heartbeat_at=None,
|
|
200
|
+
claimed_by_worker=None,
|
|
201
|
+
ray_job_id=None,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
self.message_user(
|
|
205
|
+
request,
|
|
206
|
+
f"Queued {count} task(s) for retry.",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@admin.action(description="Cancel selected tasks")
|
|
210
|
+
def cancel_tasks(self, request: HttpRequest, queryset: QuerySet[RayTaskExecution]) -> None:
|
|
211
|
+
"""Cancel queued or running tasks.
|
|
212
|
+
|
|
213
|
+
For QUEUED tasks: Marks as CANCELLED immediately.
|
|
214
|
+
For RUNNING tasks with Ray Job API: Attempts to stop the Ray job.
|
|
215
|
+
For RUNNING tasks with Ray Core: Marks as CANCELLED (worker will skip on next poll).
|
|
216
|
+
"""
|
|
217
|
+
cancellable_states = [TaskState.QUEUED, TaskState.RUNNING]
|
|
218
|
+
tasks_to_cancel = queryset.filter(state__in=cancellable_states)
|
|
219
|
+
|
|
220
|
+
if not tasks_to_cancel.exists():
|
|
221
|
+
self.message_user(
|
|
222
|
+
request,
|
|
223
|
+
"No queued or running tasks found in selection.",
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
cancelled_count = 0
|
|
228
|
+
ray_job_cancel_attempted = 0
|
|
229
|
+
|
|
230
|
+
for task in tasks_to_cancel:
|
|
231
|
+
now = timezone.now()
|
|
232
|
+
|
|
233
|
+
if task.state == TaskState.QUEUED:
|
|
234
|
+
# Queued tasks can be cancelled directly
|
|
235
|
+
task.state = TaskState.CANCELLED
|
|
236
|
+
task.finished_at = now
|
|
237
|
+
task.save(update_fields=["state", "finished_at"])
|
|
238
|
+
cancelled_count += 1
|
|
239
|
+
|
|
240
|
+
elif task.state == TaskState.RUNNING:
|
|
241
|
+
# Check if this is a Ray Job API task
|
|
242
|
+
# Ray Job API: starts with "raysubmit_"
|
|
243
|
+
# Ray Core old format: starts with "ray_core:"
|
|
244
|
+
# Ray Core new format: "job_id:task_id" (neither starts with raysubmit_ nor ray_core:)
|
|
245
|
+
ray_job_id = task.ray_job_id
|
|
246
|
+
is_ray_job_api = ray_job_id and str(ray_job_id).startswith("raysubmit_")
|
|
247
|
+
|
|
248
|
+
if is_ray_job_api:
|
|
249
|
+
# Try to stop the Ray job
|
|
250
|
+
try:
|
|
251
|
+
from django_ray.runner.ray_job import RayJobRunner
|
|
252
|
+
|
|
253
|
+
runner = RayJobRunner()
|
|
254
|
+
from datetime import UTC, datetime
|
|
255
|
+
|
|
256
|
+
from django_ray.runner.base import SubmissionHandle
|
|
257
|
+
|
|
258
|
+
handle = SubmissionHandle(
|
|
259
|
+
ray_job_id=str(ray_job_id),
|
|
260
|
+
ray_address=str(task.ray_address or ""),
|
|
261
|
+
submitted_at=task.started_at or datetime.now(UTC),
|
|
262
|
+
)
|
|
263
|
+
runner.cancel(handle)
|
|
264
|
+
ray_job_cancel_attempted += 1
|
|
265
|
+
except Exception:
|
|
266
|
+
pass # Best effort
|
|
267
|
+
|
|
268
|
+
# Mark as CANCELLING - worker will finalize on next poll
|
|
269
|
+
task.state = TaskState.CANCELLING
|
|
270
|
+
task.save(update_fields=["state"])
|
|
271
|
+
cancelled_count += 1
|
|
272
|
+
|
|
273
|
+
message = f"Marked {cancelled_count} task(s) for cancellation."
|
|
274
|
+
if ray_job_cancel_attempted:
|
|
275
|
+
message += f" Attempted to stop {ray_job_cancel_attempted} Ray job(s)."
|
|
276
|
+
|
|
277
|
+
self.message_user(request, message)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ActiveWorkerFilter(admin.SimpleListFilter):
|
|
281
|
+
"""Filter to show active/inactive workers with active as default."""
|
|
282
|
+
|
|
283
|
+
title = "status"
|
|
284
|
+
parameter_name = "is_active"
|
|
285
|
+
|
|
286
|
+
def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> list[tuple[str, str]]:
|
|
287
|
+
return [
|
|
288
|
+
("active", "Active"),
|
|
289
|
+
("inactive", "Inactive"),
|
|
290
|
+
("all", "All"),
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
def queryset(
|
|
294
|
+
self, request: HttpRequest, queryset: QuerySet[TaskWorkerLease]
|
|
295
|
+
) -> QuerySet[TaskWorkerLease]:
|
|
296
|
+
if self.value() == "inactive":
|
|
297
|
+
return queryset.filter(is_active=False)
|
|
298
|
+
elif self.value() == "all":
|
|
299
|
+
return queryset
|
|
300
|
+
else:
|
|
301
|
+
# Default: show only active
|
|
302
|
+
return queryset.filter(is_active=True)
|
|
303
|
+
|
|
304
|
+
def choices(self, changelist: Any) -> Any:
|
|
305
|
+
"""Override to set default selection."""
|
|
306
|
+
for lookup, title in self.lookup_choices:
|
|
307
|
+
yield {
|
|
308
|
+
"selected": self.value() == lookup or (self.value() is None and lookup == "active"),
|
|
309
|
+
"query_string": changelist.get_query_string({self.parameter_name: lookup}),
|
|
310
|
+
"display": title,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@admin.register(TaskWorkerLease)
|
|
315
|
+
class TaskWorkerLeaseAdmin(admin.ModelAdmin):
|
|
316
|
+
"""Admin for TaskWorkerLease model.
|
|
317
|
+
|
|
318
|
+
Note: This tracks Django task workers (django_ray_worker command),
|
|
319
|
+
NOT Ray cluster workers. The Django workers claim tasks from the
|
|
320
|
+
database and submit them to Ray for execution.
|
|
321
|
+
|
|
322
|
+
By default, only active workers are shown. Use the filter to see inactive workers.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
list_display = [
|
|
326
|
+
"worker_id_short",
|
|
327
|
+
"hostname",
|
|
328
|
+
"pid",
|
|
329
|
+
"queue_name",
|
|
330
|
+
"started_at",
|
|
331
|
+
"last_heartbeat_at",
|
|
332
|
+
"is_active_display_list",
|
|
333
|
+
"time_since_heartbeat",
|
|
334
|
+
]
|
|
335
|
+
list_filter = [
|
|
336
|
+
ActiveWorkerFilter,
|
|
337
|
+
"queue_name",
|
|
338
|
+
"hostname",
|
|
339
|
+
]
|
|
340
|
+
search_fields = [
|
|
341
|
+
"worker_id",
|
|
342
|
+
"hostname",
|
|
343
|
+
]
|
|
344
|
+
readonly_fields = [
|
|
345
|
+
"worker_id",
|
|
346
|
+
"hostname",
|
|
347
|
+
"pid",
|
|
348
|
+
"queue_name",
|
|
349
|
+
"started_at",
|
|
350
|
+
"last_heartbeat_at",
|
|
351
|
+
"stopped_at",
|
|
352
|
+
"is_active",
|
|
353
|
+
]
|
|
354
|
+
fieldsets = (
|
|
355
|
+
(
|
|
356
|
+
"Worker Identification",
|
|
357
|
+
{
|
|
358
|
+
"fields": ("worker_id", "hostname", "pid"),
|
|
359
|
+
},
|
|
360
|
+
),
|
|
361
|
+
(
|
|
362
|
+
"Configuration",
|
|
363
|
+
{
|
|
364
|
+
"fields": ("queue_name",),
|
|
365
|
+
"description": "Note: Changing the queue here does NOT affect the worker. "
|
|
366
|
+
"The queue is set when the worker starts via --queue argument.",
|
|
367
|
+
},
|
|
368
|
+
),
|
|
369
|
+
(
|
|
370
|
+
"Status",
|
|
371
|
+
{
|
|
372
|
+
"fields": ("is_active",),
|
|
373
|
+
},
|
|
374
|
+
),
|
|
375
|
+
(
|
|
376
|
+
"Timing",
|
|
377
|
+
{
|
|
378
|
+
"fields": ("started_at", "last_heartbeat_at", "stopped_at"),
|
|
379
|
+
},
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
actions = ["mark_inactive", "delete_inactive"]
|
|
383
|
+
|
|
384
|
+
def get_queryset(self, request: HttpRequest) -> QuerySet[TaskWorkerLease]:
|
|
385
|
+
"""Default queryset - filter applied via ActiveWorkerFilter."""
|
|
386
|
+
return super().get_queryset(request)
|
|
387
|
+
|
|
388
|
+
@admin.display(description="Worker ID")
|
|
389
|
+
def worker_id_short(self, obj: TaskWorkerLease) -> str:
|
|
390
|
+
"""Show shortened worker ID."""
|
|
391
|
+
worker_id = str(obj.worker_id)
|
|
392
|
+
return f"{worker_id[:12]}..."
|
|
393
|
+
|
|
394
|
+
@admin.display(boolean=True, description="Active")
|
|
395
|
+
def is_active_display_list(self, obj: TaskWorkerLease) -> bool:
|
|
396
|
+
"""Display active status as boolean icon in list view."""
|
|
397
|
+
return bool(obj.is_active) and not self._is_heartbeat_expired(obj)
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _is_heartbeat_expired(obj: TaskWorkerLease) -> bool:
|
|
401
|
+
"""Check if heartbeat has expired."""
|
|
402
|
+
from django_ray.runner.leasing import is_lease_expired
|
|
403
|
+
|
|
404
|
+
return is_lease_expired(obj)
|
|
405
|
+
|
|
406
|
+
@admin.display(description="Time Since Heartbeat")
|
|
407
|
+
def time_since_heartbeat(self, obj: TaskWorkerLease) -> str:
|
|
408
|
+
"""Show time since last heartbeat."""
|
|
409
|
+
if not obj.last_heartbeat_at:
|
|
410
|
+
return "Never"
|
|
411
|
+
delta = timezone.now() - obj.last_heartbeat_at
|
|
412
|
+
seconds = int(delta.total_seconds())
|
|
413
|
+
if seconds < 60:
|
|
414
|
+
return f"{seconds}s ago"
|
|
415
|
+
elif seconds < 3600:
|
|
416
|
+
return f"{seconds // 60}m {seconds % 60}s ago"
|
|
417
|
+
else:
|
|
418
|
+
return f"{seconds // 3600}h {(seconds % 3600) // 60}m ago"
|
|
419
|
+
|
|
420
|
+
@admin.action(description="Mark selected as inactive")
|
|
421
|
+
def mark_inactive(self, request: HttpRequest, queryset: QuerySet[TaskWorkerLease]) -> None:
|
|
422
|
+
"""Mark selected worker leases as inactive."""
|
|
423
|
+
count = queryset.filter(is_active=True).update(
|
|
424
|
+
is_active=False,
|
|
425
|
+
stopped_at=timezone.now(),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if count > 0:
|
|
429
|
+
self.message_user(
|
|
430
|
+
request,
|
|
431
|
+
f"Marked {count} worker lease(s) as inactive.",
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
self.message_user(
|
|
435
|
+
request,
|
|
436
|
+
"No active leases found in selection.",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
@admin.action(description="Delete inactive worker leases")
|
|
440
|
+
def delete_inactive(self, request: HttpRequest, queryset: QuerySet[TaskWorkerLease]) -> None:
|
|
441
|
+
"""Delete inactive worker leases from selected."""
|
|
442
|
+
deleted_count, _ = queryset.filter(is_active=False).delete()
|
|
443
|
+
|
|
444
|
+
if deleted_count > 0:
|
|
445
|
+
self.message_user(
|
|
446
|
+
request,
|
|
447
|
+
f"Deleted {deleted_count} inactive worker lease(s).",
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
self.message_user(
|
|
451
|
+
request,
|
|
452
|
+
"No inactive leases found in selection.",
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
456
|
+
"""Disable adding leases manually - workers create their own."""
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
|
|
460
|
+
"""Disable editing leases - they are managed by workers."""
|
|
461
|
+
return False
|
django_ray/apps.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Django app configuration for django-ray."""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DjangoRayConfig(AppConfig):
|
|
7
|
+
"""Configuration for django-ray Django app."""
|
|
8
|
+
|
|
9
|
+
name = "django_ray"
|
|
10
|
+
verbose_name = "Django Ray"
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
12
|
+
|
|
13
|
+
def ready(self) -> None:
|
|
14
|
+
"""Initialize the app when Django starts."""
|
|
15
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
16
|
+
|
|
17
|
+
from django_ray.conf.settings import validate_settings
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
validate_settings()
|
|
21
|
+
except ImproperlyConfigured:
|
|
22
|
+
# Allow startup without RAY_ADDRESS for migrations, etc.
|
|
23
|
+
pass
|