django-cfg 1.2.17__py3-none-any.whl → 1.2.19__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_cfg/__init__.py +1 -1
- django_cfg/apps/accounts/models/__init__.py +68 -0
- django_cfg/apps/accounts/models/activity.py +34 -0
- django_cfg/apps/accounts/models/auth.py +50 -0
- django_cfg/apps/accounts/models/base.py +8 -0
- django_cfg/apps/accounts/models/choices.py +32 -0
- django_cfg/apps/accounts/models/integrations.py +75 -0
- django_cfg/apps/accounts/models/registration.py +52 -0
- django_cfg/apps/accounts/models/user.py +80 -0
- django_cfg/apps/maintenance/__init__.py +53 -24
- django_cfg/apps/maintenance/admin/__init__.py +7 -18
- django_cfg/apps/maintenance/admin/api_key_admin.py +185 -0
- django_cfg/apps/maintenance/admin/log_admin.py +156 -0
- django_cfg/apps/maintenance/admin/scheduled_admin.py +390 -0
- django_cfg/apps/maintenance/admin/site_admin.py +448 -0
- django_cfg/apps/maintenance/apps.py +9 -96
- django_cfg/apps/maintenance/management/commands/maintenance.py +193 -307
- django_cfg/apps/maintenance/management/commands/process_scheduled_maintenance.py +241 -0
- django_cfg/apps/maintenance/management/commands/sync_cloudflare.py +152 -111
- django_cfg/apps/maintenance/managers/__init__.py +7 -12
- django_cfg/apps/maintenance/managers/cloudflare_site_manager.py +192 -0
- django_cfg/apps/maintenance/managers/maintenance_log_manager.py +151 -0
- django_cfg/apps/maintenance/migrations/0001_initial.py +145 -705
- django_cfg/apps/maintenance/migrations/0002_cloudflaresite_maintenance_url.py +21 -0
- django_cfg/apps/maintenance/models/__init__.py +23 -21
- django_cfg/apps/maintenance/models/cloudflare_api_key.py +109 -0
- django_cfg/apps/maintenance/models/cloudflare_site.py +125 -0
- django_cfg/apps/maintenance/models/maintenance_log.py +131 -0
- django_cfg/apps/maintenance/models/scheduled_maintenance.py +307 -0
- django_cfg/apps/maintenance/services/__init__.py +37 -16
- django_cfg/apps/maintenance/services/bulk_operations_service.py +400 -0
- django_cfg/apps/maintenance/services/maintenance_service.py +230 -0
- django_cfg/apps/maintenance/services/scheduled_maintenance_service.py +381 -0
- django_cfg/apps/maintenance/services/site_sync_service.py +390 -0
- django_cfg/apps/maintenance/utils/__init__.py +12 -0
- django_cfg/apps/maintenance/utils/retry_utils.py +109 -0
- django_cfg/config.py +4 -0
- django_cfg/core/config.py +4 -6
- django_cfg/modules/django_unfold/dashboard.py +4 -5
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.19.dist-info}/METADATA +52 -1
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.19.dist-info}/RECORD +45 -55
- django_cfg/apps/maintenance/README.md +0 -305
- django_cfg/apps/maintenance/admin/deployments_admin.py +0 -251
- django_cfg/apps/maintenance/admin/events_admin.py +0 -374
- django_cfg/apps/maintenance/admin/monitoring_admin.py +0 -215
- django_cfg/apps/maintenance/admin/sites_admin.py +0 -464
- django_cfg/apps/maintenance/managers/deployments.py +0 -287
- django_cfg/apps/maintenance/managers/events.py +0 -374
- django_cfg/apps/maintenance/managers/monitoring.py +0 -301
- django_cfg/apps/maintenance/managers/sites.py +0 -335
- django_cfg/apps/maintenance/models/cloudflare.py +0 -316
- django_cfg/apps/maintenance/models/maintenance.py +0 -334
- django_cfg/apps/maintenance/models/monitoring.py +0 -393
- django_cfg/apps/maintenance/models/sites.py +0 -419
- django_cfg/apps/maintenance/serializers/__init__.py +0 -60
- django_cfg/apps/maintenance/serializers/actions.py +0 -310
- django_cfg/apps/maintenance/serializers/base.py +0 -44
- django_cfg/apps/maintenance/serializers/deployments.py +0 -209
- django_cfg/apps/maintenance/serializers/events.py +0 -210
- django_cfg/apps/maintenance/serializers/monitoring.py +0 -278
- django_cfg/apps/maintenance/serializers/sites.py +0 -213
- django_cfg/apps/maintenance/services/README.md +0 -168
- django_cfg/apps/maintenance/services/cloudflare_client.py +0 -441
- django_cfg/apps/maintenance/services/dns_manager.py +0 -497
- django_cfg/apps/maintenance/services/maintenance_manager.py +0 -504
- django_cfg/apps/maintenance/services/site_sync.py +0 -448
- django_cfg/apps/maintenance/services/sync_command_service.py +0 -330
- django_cfg/apps/maintenance/services/worker_manager.py +0 -264
- django_cfg/apps/maintenance/signals.py +0 -38
- django_cfg/apps/maintenance/urls.py +0 -36
- django_cfg/apps/maintenance/views/__init__.py +0 -18
- django_cfg/apps/maintenance/views/base.py +0 -61
- django_cfg/apps/maintenance/views/deployments.py +0 -175
- django_cfg/apps/maintenance/views/events.py +0 -204
- django_cfg/apps/maintenance/views/monitoring.py +0 -213
- django_cfg/apps/maintenance/views/sites.py +0 -338
- django_cfg/models/cloudflare.py +0 -316
- /django_cfg/apps/accounts/{models.py → __models.py} +0 -0
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.19.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.19.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.19.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,390 @@
|
|
1
|
+
"""
|
2
|
+
Admin interface for ScheduledMaintenance with Unfold styling.
|
3
|
+
|
4
|
+
Provides comprehensive management of scheduled maintenance events.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from django.contrib import admin
|
8
|
+
from django.utils.html import format_html
|
9
|
+
from django.http import HttpRequest, HttpResponse
|
10
|
+
from django.shortcuts import redirect
|
11
|
+
from django.contrib import messages
|
12
|
+
from django.utils import timezone
|
13
|
+
from django.urls import path, reverse
|
14
|
+
from django.template.response import TemplateResponse
|
15
|
+
from typing import Any
|
16
|
+
|
17
|
+
from unfold.admin import ModelAdmin
|
18
|
+
from unfold.decorators import display, action
|
19
|
+
from unfold.enums import ActionVariant
|
20
|
+
|
21
|
+
from ..models import ScheduledMaintenance, CloudflareSite
|
22
|
+
|
23
|
+
|
24
|
+
@admin.register(ScheduledMaintenance)
|
25
|
+
class ScheduledMaintenanceAdmin(ModelAdmin):
|
26
|
+
"""Admin for ScheduledMaintenance with Unfold styling."""
|
27
|
+
|
28
|
+
list_display = [
|
29
|
+
"status_display",
|
30
|
+
"title",
|
31
|
+
"scheduled_start",
|
32
|
+
"duration_display",
|
33
|
+
"sites_count",
|
34
|
+
"priority_badge",
|
35
|
+
"auto_flags",
|
36
|
+
"created_at",
|
37
|
+
]
|
38
|
+
list_display_links = ["title"]
|
39
|
+
search_fields = ["title", "description", "maintenance_message"]
|
40
|
+
list_filter = [
|
41
|
+
"status",
|
42
|
+
"priority",
|
43
|
+
"auto_enable",
|
44
|
+
"auto_disable",
|
45
|
+
"scheduled_start",
|
46
|
+
"created_at"
|
47
|
+
]
|
48
|
+
ordering = ["-scheduled_start"]
|
49
|
+
|
50
|
+
fieldsets = [
|
51
|
+
("Basic Information", {
|
52
|
+
'fields': ['title', 'description', 'priority', 'created_by']
|
53
|
+
}),
|
54
|
+
("Scheduling", {
|
55
|
+
'fields': ['scheduled_start', 'estimated_duration', 'scheduled_end']
|
56
|
+
}),
|
57
|
+
("Sites", {
|
58
|
+
'fields': ['sites']
|
59
|
+
}),
|
60
|
+
("Configuration", {
|
61
|
+
'fields': ['maintenance_message', 'template', 'auto_enable', 'auto_disable']
|
62
|
+
}),
|
63
|
+
("Notifications", {
|
64
|
+
'fields': ['notify_before', 'notify_on_start', 'notify_on_complete'],
|
65
|
+
'classes': ['collapse']
|
66
|
+
}),
|
67
|
+
("Execution Status", {
|
68
|
+
'fields': ['status', 'actual_start', 'actual_end'],
|
69
|
+
'classes': ['collapse']
|
70
|
+
}),
|
71
|
+
("Execution Log", {
|
72
|
+
'fields': ['execution_log'],
|
73
|
+
'classes': ['collapse']
|
74
|
+
}),
|
75
|
+
]
|
76
|
+
|
77
|
+
readonly_fields = ['scheduled_end', 'actual_start', 'actual_end', 'execution_log']
|
78
|
+
|
79
|
+
filter_horizontal = ['sites']
|
80
|
+
|
81
|
+
actions = [
|
82
|
+
'start_maintenance_action',
|
83
|
+
'complete_maintenance_action',
|
84
|
+
'cancel_maintenance_action',
|
85
|
+
'duplicate_maintenance_action'
|
86
|
+
]
|
87
|
+
|
88
|
+
def get_urls(self):
|
89
|
+
"""Add custom admin URLs."""
|
90
|
+
urls = super().get_urls()
|
91
|
+
custom_urls = [
|
92
|
+
path(
|
93
|
+
'calendar/',
|
94
|
+
self.admin_site.admin_view(self.calendar_view),
|
95
|
+
name='scheduled_maintenance_calendar'
|
96
|
+
),
|
97
|
+
path(
|
98
|
+
'<int:object_id>/start/',
|
99
|
+
self.admin_site.admin_view(self.start_maintenance_view),
|
100
|
+
name='scheduled_maintenance_start'
|
101
|
+
),
|
102
|
+
path(
|
103
|
+
'<int:object_id>/complete/',
|
104
|
+
self.admin_site.admin_view(self.complete_maintenance_view),
|
105
|
+
name='scheduled_maintenance_complete'
|
106
|
+
),
|
107
|
+
]
|
108
|
+
return custom_urls + urls
|
109
|
+
|
110
|
+
@display(description="Status")
|
111
|
+
def status_display(self, obj: ScheduledMaintenance) -> str:
|
112
|
+
"""Display status with colored badge and timing info."""
|
113
|
+
status_config = {
|
114
|
+
ScheduledMaintenance.Status.SCHEDULED: {
|
115
|
+
'emoji': '📅',
|
116
|
+
'color': 'blue',
|
117
|
+
'text': 'Scheduled'
|
118
|
+
},
|
119
|
+
ScheduledMaintenance.Status.ACTIVE: {
|
120
|
+
'emoji': '🔧',
|
121
|
+
'color': 'orange',
|
122
|
+
'text': 'Active'
|
123
|
+
},
|
124
|
+
ScheduledMaintenance.Status.COMPLETED: {
|
125
|
+
'emoji': '✅',
|
126
|
+
'color': 'green',
|
127
|
+
'text': 'Completed'
|
128
|
+
},
|
129
|
+
ScheduledMaintenance.Status.CANCELLED: {
|
130
|
+
'emoji': '❌',
|
131
|
+
'color': 'red',
|
132
|
+
'text': 'Cancelled'
|
133
|
+
},
|
134
|
+
ScheduledMaintenance.Status.FAILED: {
|
135
|
+
'emoji': '💥',
|
136
|
+
'color': 'red',
|
137
|
+
'text': 'Failed'
|
138
|
+
},
|
139
|
+
}
|
140
|
+
|
141
|
+
config = status_config.get(obj.status, {
|
142
|
+
'emoji': '❓',
|
143
|
+
'color': 'gray',
|
144
|
+
'text': obj.get_status_display()
|
145
|
+
})
|
146
|
+
|
147
|
+
# Add timing info
|
148
|
+
timing_info = ""
|
149
|
+
if obj.status == ScheduledMaintenance.Status.SCHEDULED:
|
150
|
+
if obj.is_due:
|
151
|
+
timing_info = " <small>(Due now!)</small>"
|
152
|
+
elif obj.time_until_start:
|
153
|
+
hours = int(obj.time_until_start.total_seconds() // 3600)
|
154
|
+
if hours < 24:
|
155
|
+
timing_info = f" <small>(in {hours}h)</small>"
|
156
|
+
elif obj.status == ScheduledMaintenance.Status.ACTIVE:
|
157
|
+
if obj.is_overdue:
|
158
|
+
timing_info = " <small>(Overdue!)</small>"
|
159
|
+
elif obj.time_until_end:
|
160
|
+
hours = int(obj.time_until_end.total_seconds() // 3600)
|
161
|
+
minutes = int((obj.time_until_end.total_seconds() % 3600) // 60)
|
162
|
+
timing_info = f" <small>({hours}h {minutes}m left)</small>"
|
163
|
+
|
164
|
+
return format_html(
|
165
|
+
'<span style="color: {};">{} {}</span>{}',
|
166
|
+
config['color'],
|
167
|
+
config['emoji'],
|
168
|
+
config['text'],
|
169
|
+
timing_info
|
170
|
+
)
|
171
|
+
|
172
|
+
@display(description="Duration")
|
173
|
+
def duration_display(self, obj: ScheduledMaintenance) -> str:
|
174
|
+
"""Display estimated vs actual duration."""
|
175
|
+
estimated_hours = obj.estimated_duration.total_seconds() / 3600
|
176
|
+
|
177
|
+
if obj.actual_duration:
|
178
|
+
actual_hours = obj.actual_duration.total_seconds() / 3600
|
179
|
+
return format_html(
|
180
|
+
'{:.1f}h <small>(actual: {:.1f}h)</small>',
|
181
|
+
estimated_hours,
|
182
|
+
actual_hours
|
183
|
+
)
|
184
|
+
|
185
|
+
return f"{estimated_hours:.1f}h"
|
186
|
+
|
187
|
+
@display(description="Sites")
|
188
|
+
def sites_count(self, obj: ScheduledMaintenance) -> str:
|
189
|
+
"""Display sites count with link."""
|
190
|
+
count = obj.affected_sites_count
|
191
|
+
if count == 0:
|
192
|
+
return format_html('<span style="color: red;">No sites</span>')
|
193
|
+
|
194
|
+
return format_html(
|
195
|
+
'<span class="badge badge-info">{} sites</span>',
|
196
|
+
count
|
197
|
+
)
|
198
|
+
|
199
|
+
@display(description="Priority")
|
200
|
+
def priority_badge(self, obj: ScheduledMaintenance) -> str:
|
201
|
+
"""Display priority badge."""
|
202
|
+
priority_config = {
|
203
|
+
'low': {'color': 'green', 'emoji': '🟢'},
|
204
|
+
'normal': {'color': 'blue', 'emoji': '🟡'},
|
205
|
+
'high': {'color': 'orange', 'emoji': '🟠'},
|
206
|
+
'critical': {'color': 'red', 'emoji': '🔴'},
|
207
|
+
}
|
208
|
+
|
209
|
+
config = priority_config.get(obj.priority, {'color': 'gray', 'emoji': '⚪'})
|
210
|
+
|
211
|
+
return format_html(
|
212
|
+
'<span style="color: {};">{} {}</span>',
|
213
|
+
config['color'],
|
214
|
+
config['emoji'],
|
215
|
+
obj.get_priority_display()
|
216
|
+
)
|
217
|
+
|
218
|
+
@display(description="Auto")
|
219
|
+
def auto_flags(self, obj: ScheduledMaintenance) -> str:
|
220
|
+
"""Display automation flags."""
|
221
|
+
flags = []
|
222
|
+
|
223
|
+
if obj.auto_enable:
|
224
|
+
flags.append('<span style="color: green;">▶️ Start</span>')
|
225
|
+
|
226
|
+
if obj.auto_disable:
|
227
|
+
flags.append('<span style="color: blue;">⏹️ Stop</span>')
|
228
|
+
|
229
|
+
if not flags:
|
230
|
+
return '<span style="color: gray;">Manual</span>'
|
231
|
+
|
232
|
+
return format_html(' '.join(flags))
|
233
|
+
|
234
|
+
@action(description="Start Maintenance", variant=ActionVariant.SUCCESS)
|
235
|
+
def start_maintenance_action(self, request: HttpRequest, queryset: Any) -> None:
|
236
|
+
"""Start selected maintenance events."""
|
237
|
+
started = 0
|
238
|
+
failed = 0
|
239
|
+
|
240
|
+
for maintenance in queryset:
|
241
|
+
if maintenance.status == ScheduledMaintenance.Status.SCHEDULED:
|
242
|
+
try:
|
243
|
+
result = maintenance.start_maintenance()
|
244
|
+
if result['success']:
|
245
|
+
started += 1
|
246
|
+
else:
|
247
|
+
failed += 1
|
248
|
+
except Exception as e:
|
249
|
+
failed += 1
|
250
|
+
messages.error(request, f"Failed to start {maintenance.title}: {e}")
|
251
|
+
|
252
|
+
if started > 0:
|
253
|
+
messages.success(request, f"Started {started} maintenance events")
|
254
|
+
if failed > 0:
|
255
|
+
messages.error(request, f"Failed to start {failed} maintenance events")
|
256
|
+
|
257
|
+
@action(description="Complete Maintenance", variant=ActionVariant.PRIMARY)
|
258
|
+
def complete_maintenance_action(self, request: HttpRequest, queryset: Any) -> None:
|
259
|
+
"""Complete selected maintenance events."""
|
260
|
+
completed = 0
|
261
|
+
failed = 0
|
262
|
+
|
263
|
+
for maintenance in queryset:
|
264
|
+
if maintenance.status == ScheduledMaintenance.Status.ACTIVE:
|
265
|
+
try:
|
266
|
+
result = maintenance.complete_maintenance()
|
267
|
+
if result['success']:
|
268
|
+
completed += 1
|
269
|
+
else:
|
270
|
+
failed += 1
|
271
|
+
except Exception as e:
|
272
|
+
failed += 1
|
273
|
+
messages.error(request, f"Failed to complete {maintenance.title}: {e}")
|
274
|
+
|
275
|
+
if completed > 0:
|
276
|
+
messages.success(request, f"Completed {completed} maintenance events")
|
277
|
+
if failed > 0:
|
278
|
+
messages.error(request, f"Failed to complete {failed} maintenance events")
|
279
|
+
|
280
|
+
@action(description="Cancel Maintenance", variant=ActionVariant.DANGER)
|
281
|
+
def cancel_maintenance_action(self, request: HttpRequest, queryset: Any) -> None:
|
282
|
+
"""Cancel selected maintenance events."""
|
283
|
+
cancelled = 0
|
284
|
+
|
285
|
+
for maintenance in queryset:
|
286
|
+
if maintenance.status in [ScheduledMaintenance.Status.SCHEDULED, ScheduledMaintenance.Status.ACTIVE]:
|
287
|
+
try:
|
288
|
+
result = maintenance.cancel_maintenance(reason="Cancelled via admin")
|
289
|
+
if result['success']:
|
290
|
+
cancelled += 1
|
291
|
+
except Exception as e:
|
292
|
+
messages.error(request, f"Failed to cancel {maintenance.title}: {e}")
|
293
|
+
|
294
|
+
if cancelled > 0:
|
295
|
+
messages.success(request, f"Cancelled {cancelled} maintenance events")
|
296
|
+
|
297
|
+
@action(description="Duplicate Maintenance")
|
298
|
+
def duplicate_maintenance_action(self, request: HttpRequest, queryset: Any) -> None:
|
299
|
+
"""Duplicate selected maintenance events."""
|
300
|
+
duplicated = 0
|
301
|
+
|
302
|
+
for maintenance in queryset:
|
303
|
+
try:
|
304
|
+
# Create duplicate with new start time (1 week later)
|
305
|
+
new_start = maintenance.scheduled_start + timezone.timedelta(weeks=1)
|
306
|
+
|
307
|
+
duplicate = ScheduledMaintenance.objects.create(
|
308
|
+
title=f"{maintenance.title} (Copy)",
|
309
|
+
description=maintenance.description,
|
310
|
+
scheduled_start=new_start,
|
311
|
+
estimated_duration=maintenance.estimated_duration,
|
312
|
+
maintenance_message=maintenance.maintenance_message,
|
313
|
+
template=maintenance.template,
|
314
|
+
priority=maintenance.priority,
|
315
|
+
auto_enable=maintenance.auto_enable,
|
316
|
+
auto_disable=maintenance.auto_disable,
|
317
|
+
notify_before=maintenance.notify_before,
|
318
|
+
created_by=f"{maintenance.created_by} (duplicate)"
|
319
|
+
)
|
320
|
+
|
321
|
+
# Copy sites
|
322
|
+
duplicate.sites.set(maintenance.sites.all())
|
323
|
+
duplicated += 1
|
324
|
+
|
325
|
+
except Exception as e:
|
326
|
+
messages.error(request, f"Failed to duplicate {maintenance.title}: {e}")
|
327
|
+
|
328
|
+
if duplicated > 0:
|
329
|
+
messages.success(request, f"Duplicated {duplicated} maintenance events")
|
330
|
+
|
331
|
+
def calendar_view(self, request: HttpRequest) -> TemplateResponse:
|
332
|
+
"""Calendar view for scheduled maintenances."""
|
333
|
+
from ..services.scheduled_maintenance_service import scheduled_maintenance_service
|
334
|
+
|
335
|
+
calendar_data = scheduled_maintenance_service.get_maintenance_calendar(days=30)
|
336
|
+
|
337
|
+
context = {
|
338
|
+
'title': 'Maintenance Calendar',
|
339
|
+
'calendar_data': calendar_data,
|
340
|
+
'opts': self.model._meta,
|
341
|
+
}
|
342
|
+
|
343
|
+
return TemplateResponse(
|
344
|
+
request,
|
345
|
+
'admin/maintenance/scheduled_maintenance_calendar.html',
|
346
|
+
context
|
347
|
+
)
|
348
|
+
|
349
|
+
def start_maintenance_view(self, request: HttpRequest, object_id: int) -> HttpResponse:
|
350
|
+
"""Start specific maintenance event."""
|
351
|
+
maintenance = self.get_object(request, object_id)
|
352
|
+
|
353
|
+
if maintenance.status != ScheduledMaintenance.Status.SCHEDULED:
|
354
|
+
messages.error(request, f"Cannot start maintenance in {maintenance.status} status")
|
355
|
+
else:
|
356
|
+
try:
|
357
|
+
result = maintenance.start_maintenance()
|
358
|
+
if result['success']:
|
359
|
+
messages.success(
|
360
|
+
request,
|
361
|
+
f"Started maintenance '{maintenance.title}' affecting {result['sites_affected']} sites"
|
362
|
+
)
|
363
|
+
else:
|
364
|
+
messages.error(request, f"Failed to start maintenance: {result.get('error')}")
|
365
|
+
except Exception as e:
|
366
|
+
messages.error(request, f"Error starting maintenance: {e}")
|
367
|
+
|
368
|
+
return redirect('admin:maintenance_scheduledmaintenance_change', object_id)
|
369
|
+
|
370
|
+
def complete_maintenance_view(self, request: HttpRequest, object_id: int) -> HttpResponse:
|
371
|
+
"""Complete specific maintenance event."""
|
372
|
+
maintenance = self.get_object(request, object_id)
|
373
|
+
|
374
|
+
if maintenance.status != ScheduledMaintenance.Status.ACTIVE:
|
375
|
+
messages.error(request, f"Cannot complete maintenance in {maintenance.status} status")
|
376
|
+
else:
|
377
|
+
try:
|
378
|
+
result = maintenance.complete_maintenance()
|
379
|
+
if result['success']:
|
380
|
+
duration = result.get('actual_duration', 0) / 3600
|
381
|
+
messages.success(
|
382
|
+
request,
|
383
|
+
f"Completed maintenance '{maintenance.title}' (duration: {duration:.1f}h)"
|
384
|
+
)
|
385
|
+
else:
|
386
|
+
messages.error(request, f"Failed to complete maintenance: {result.get('error')}")
|
387
|
+
except Exception as e:
|
388
|
+
messages.error(request, f"Error completing maintenance: {e}")
|
389
|
+
|
390
|
+
return redirect('admin:maintenance_scheduledmaintenance_change', object_id)
|