django-cfg 1.2.16__py3-none-any.whl → 1.2.18__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 +3 -0
- django_cfg/core/config.py +4 -6
- django_cfg/modules/django_unfold/dashboard.py +4 -5
- {django_cfg-1.2.16.dist-info → django_cfg-1.2.18.dist-info}/METADATA +52 -1
- {django_cfg-1.2.16.dist-info → django_cfg-1.2.18.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.16.dist-info → django_cfg-1.2.18.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.16.dist-info → django_cfg-1.2.18.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.16.dist-info → django_cfg-1.2.18.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,400 @@
|
|
1
|
+
"""
|
2
|
+
Bulk operations service for managing multiple sites.
|
3
|
+
|
4
|
+
Provides ORM-like interface for bulk maintenance operations across multiple sites.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
import asyncio
|
9
|
+
from typing import List, Dict, Any, Optional, Union
|
10
|
+
from django.db.models import QuerySet, Q
|
11
|
+
from django.utils import timezone
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
13
|
+
|
14
|
+
from ..models import CloudflareSite, MaintenanceLog, CloudflareApiKey
|
15
|
+
from .maintenance_service import MaintenanceService
|
16
|
+
from ..utils.retry_utils import retry_on_failure
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class SiteQuerySet:
|
22
|
+
"""ORM-like interface for site operations."""
|
23
|
+
|
24
|
+
def __init__(self, queryset: QuerySet[CloudflareSite]):
|
25
|
+
self.queryset = queryset
|
26
|
+
|
27
|
+
def filter(self, **kwargs) -> 'SiteQuerySet':
|
28
|
+
"""Filter sites with Django ORM syntax."""
|
29
|
+
return SiteQuerySet(self.queryset.filter(**kwargs))
|
30
|
+
|
31
|
+
def exclude(self, **kwargs) -> 'SiteQuerySet':
|
32
|
+
"""Exclude sites with Django ORM syntax."""
|
33
|
+
return SiteQuerySet(self.queryset.exclude(**kwargs))
|
34
|
+
|
35
|
+
def active(self) -> 'SiteQuerySet':
|
36
|
+
"""Filter active sites."""
|
37
|
+
return SiteQuerySet(self.queryset.filter(is_active=True))
|
38
|
+
|
39
|
+
def in_maintenance(self) -> 'SiteQuerySet':
|
40
|
+
"""Filter sites currently in maintenance."""
|
41
|
+
return SiteQuerySet(self.queryset.filter(maintenance_active=True))
|
42
|
+
|
43
|
+
def not_in_maintenance(self) -> 'SiteQuerySet':
|
44
|
+
"""Filter sites not in maintenance."""
|
45
|
+
return SiteQuerySet(self.queryset.filter(maintenance_active=False))
|
46
|
+
|
47
|
+
def by_api_key(self, api_key: Union[CloudflareApiKey, str]) -> 'SiteQuerySet':
|
48
|
+
"""Filter sites by API key."""
|
49
|
+
if isinstance(api_key, str):
|
50
|
+
return SiteQuerySet(self.queryset.filter(api_key__name=api_key))
|
51
|
+
return SiteQuerySet(self.queryset.filter(api_key=api_key))
|
52
|
+
|
53
|
+
def search(self, query: str) -> 'SiteQuerySet':
|
54
|
+
"""Search sites by name or domain."""
|
55
|
+
return SiteQuerySet(
|
56
|
+
self.queryset.filter(
|
57
|
+
Q(name__icontains=query) | Q(domain__icontains=query)
|
58
|
+
)
|
59
|
+
)
|
60
|
+
|
61
|
+
def count(self) -> int:
|
62
|
+
"""Count sites in queryset."""
|
63
|
+
return self.queryset.count()
|
64
|
+
|
65
|
+
def all(self) -> List[CloudflareSite]:
|
66
|
+
"""Get all sites in queryset."""
|
67
|
+
return list(self.queryset.all())
|
68
|
+
|
69
|
+
def first(self) -> Optional[CloudflareSite]:
|
70
|
+
"""Get first site in queryset."""
|
71
|
+
return self.queryset.first()
|
72
|
+
|
73
|
+
def get(self, **kwargs) -> CloudflareSite:
|
74
|
+
"""Get single site."""
|
75
|
+
return self.queryset.get(**kwargs)
|
76
|
+
|
77
|
+
# Bulk Operations
|
78
|
+
def enable_maintenance(self,
|
79
|
+
reason: str = "Bulk maintenance operation",
|
80
|
+
template: str = "modern",
|
81
|
+
max_workers: int = 5,
|
82
|
+
dry_run: bool = False) -> Dict[str, Any]:
|
83
|
+
"""Enable maintenance mode for all sites in queryset."""
|
84
|
+
sites = self.queryset.filter(maintenance_active=False)
|
85
|
+
|
86
|
+
if dry_run:
|
87
|
+
return {
|
88
|
+
'total': sites.count(),
|
89
|
+
'would_affect': [site.domain for site in sites],
|
90
|
+
'dry_run': True
|
91
|
+
}
|
92
|
+
|
93
|
+
return self._execute_bulk_operation(
|
94
|
+
sites=sites,
|
95
|
+
operation='enable',
|
96
|
+
reason=reason,
|
97
|
+
template=template,
|
98
|
+
max_workers=max_workers
|
99
|
+
)
|
100
|
+
|
101
|
+
def disable_maintenance(self,
|
102
|
+
max_workers: int = 5,
|
103
|
+
dry_run: bool = False) -> Dict[str, Any]:
|
104
|
+
"""Disable maintenance mode for all sites in queryset."""
|
105
|
+
sites = self.queryset.filter(maintenance_active=True)
|
106
|
+
|
107
|
+
if dry_run:
|
108
|
+
return {
|
109
|
+
'total': sites.count(),
|
110
|
+
'would_affect': [site.domain for site in sites],
|
111
|
+
'dry_run': True
|
112
|
+
}
|
113
|
+
|
114
|
+
return self._execute_bulk_operation(
|
115
|
+
sites=sites,
|
116
|
+
operation='disable',
|
117
|
+
max_workers=max_workers
|
118
|
+
)
|
119
|
+
|
120
|
+
def check_status(self, max_workers: int = 10) -> Dict[str, Any]:
|
121
|
+
"""Check status of all sites in queryset."""
|
122
|
+
sites = self.queryset.all()
|
123
|
+
|
124
|
+
return self._execute_bulk_operation(
|
125
|
+
sites=sites,
|
126
|
+
operation='status',
|
127
|
+
max_workers=max_workers
|
128
|
+
)
|
129
|
+
|
130
|
+
def sync_from_cloudflare(self, max_workers: int = 5) -> Dict[str, Any]:
|
131
|
+
"""Sync all sites from Cloudflare."""
|
132
|
+
sites = self.queryset.all()
|
133
|
+
|
134
|
+
return self._execute_bulk_operation(
|
135
|
+
sites=sites,
|
136
|
+
operation='sync',
|
137
|
+
max_workers=max_workers
|
138
|
+
)
|
139
|
+
|
140
|
+
def _execute_bulk_operation(self,
|
141
|
+
sites: QuerySet[CloudflareSite],
|
142
|
+
operation: str,
|
143
|
+
max_workers: int = 5,
|
144
|
+
**kwargs) -> Dict[str, Any]:
|
145
|
+
"""Execute bulk operation on sites with threading."""
|
146
|
+
results = {
|
147
|
+
'total': sites.count(),
|
148
|
+
'successful': [],
|
149
|
+
'failed': [],
|
150
|
+
'skipped': [],
|
151
|
+
'operation': operation,
|
152
|
+
'started_at': timezone.now().isoformat()
|
153
|
+
}
|
154
|
+
|
155
|
+
if results['total'] == 0:
|
156
|
+
return results
|
157
|
+
|
158
|
+
# Execute operations in parallel using ThreadPoolExecutor
|
159
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
160
|
+
# Submit all tasks
|
161
|
+
future_to_site = {
|
162
|
+
executor.submit(self._execute_single_operation, site, operation, **kwargs): site
|
163
|
+
for site in sites
|
164
|
+
}
|
165
|
+
|
166
|
+
# Collect results
|
167
|
+
for future in as_completed(future_to_site):
|
168
|
+
site = future_to_site[future]
|
169
|
+
try:
|
170
|
+
result = future.result()
|
171
|
+
if result['success']:
|
172
|
+
results['successful'].append({
|
173
|
+
'domain': site.domain,
|
174
|
+
'result': result.get('data')
|
175
|
+
})
|
176
|
+
else:
|
177
|
+
results['failed'].append({
|
178
|
+
'domain': site.domain,
|
179
|
+
'error': result.get('error', 'Unknown error')
|
180
|
+
})
|
181
|
+
except Exception as e:
|
182
|
+
logger.error(f"Bulk operation failed for {site.domain}: {e}")
|
183
|
+
results['failed'].append({
|
184
|
+
'domain': site.domain,
|
185
|
+
'error': str(e)
|
186
|
+
})
|
187
|
+
|
188
|
+
results['completed_at'] = timezone.now().isoformat()
|
189
|
+
results['success_rate'] = len(results['successful']) / results['total'] * 100
|
190
|
+
|
191
|
+
logger.info(f"Bulk {operation} completed: {len(results['successful'])}/{results['total']} successful")
|
192
|
+
return results
|
193
|
+
|
194
|
+
@retry_on_failure(max_retries=2)
|
195
|
+
def _execute_single_operation(self,
|
196
|
+
site: CloudflareSite,
|
197
|
+
operation: str,
|
198
|
+
**kwargs) -> Dict[str, Any]:
|
199
|
+
"""Execute single operation on a site."""
|
200
|
+
try:
|
201
|
+
service = MaintenanceService(site)
|
202
|
+
|
203
|
+
if operation == 'enable':
|
204
|
+
log_entry = service.enable_maintenance(
|
205
|
+
reason=kwargs.get('reason', 'Bulk operation'),
|
206
|
+
template=kwargs.get('template', 'modern')
|
207
|
+
)
|
208
|
+
return {
|
209
|
+
'success': log_entry.status == MaintenanceLog.Status.SUCCESS,
|
210
|
+
'data': {
|
211
|
+
'log_id': log_entry.id,
|
212
|
+
'duration': log_entry.duration_seconds
|
213
|
+
},
|
214
|
+
'error': log_entry.error_message if log_entry.status == MaintenanceLog.Status.FAILED else None
|
215
|
+
}
|
216
|
+
|
217
|
+
elif operation == 'disable':
|
218
|
+
log_entry = service.disable_maintenance()
|
219
|
+
return {
|
220
|
+
'success': log_entry.status == MaintenanceLog.Status.SUCCESS,
|
221
|
+
'data': {
|
222
|
+
'log_id': log_entry.id,
|
223
|
+
'duration': log_entry.duration_seconds
|
224
|
+
},
|
225
|
+
'error': log_entry.error_message if log_entry.status == MaintenanceLog.Status.FAILED else None
|
226
|
+
}
|
227
|
+
|
228
|
+
elif operation == 'status':
|
229
|
+
status = service.get_status()
|
230
|
+
return {
|
231
|
+
'success': True,
|
232
|
+
'data': {
|
233
|
+
'maintenance_active': status,
|
234
|
+
'last_checked': timezone.now().isoformat()
|
235
|
+
}
|
236
|
+
}
|
237
|
+
|
238
|
+
elif operation == 'sync':
|
239
|
+
from .site_sync_service import sync_site_from_cloudflare
|
240
|
+
log_entry = sync_site_from_cloudflare(site)
|
241
|
+
return {
|
242
|
+
'success': log_entry.status == MaintenanceLog.Status.SUCCESS,
|
243
|
+
'data': {
|
244
|
+
'log_id': log_entry.id,
|
245
|
+
'response': log_entry.cloudflare_response
|
246
|
+
},
|
247
|
+
'error': log_entry.error_message if log_entry.status == MaintenanceLog.Status.FAILED else None
|
248
|
+
}
|
249
|
+
|
250
|
+
else:
|
251
|
+
return {
|
252
|
+
'success': False,
|
253
|
+
'error': f'Unknown operation: {operation}'
|
254
|
+
}
|
255
|
+
|
256
|
+
except Exception as e:
|
257
|
+
logger.error(f"Operation {operation} failed for {site.domain}: {e}")
|
258
|
+
return {
|
259
|
+
'success': False,
|
260
|
+
'error': str(e)
|
261
|
+
}
|
262
|
+
|
263
|
+
|
264
|
+
class BulkOperationsService:
|
265
|
+
"""Main service for bulk operations on sites."""
|
266
|
+
|
267
|
+
def __init__(self):
|
268
|
+
"""Initialize bulk operations service."""
|
269
|
+
pass
|
270
|
+
|
271
|
+
def sites(self, queryset: Optional[QuerySet[CloudflareSite]] = None) -> SiteQuerySet:
|
272
|
+
"""Get sites queryset for bulk operations."""
|
273
|
+
if queryset is None:
|
274
|
+
queryset = CloudflareSite.objects.all()
|
275
|
+
|
276
|
+
return SiteQuerySet(queryset)
|
277
|
+
|
278
|
+
def all_sites(self) -> SiteQuerySet:
|
279
|
+
"""Get all sites."""
|
280
|
+
return self.sites()
|
281
|
+
|
282
|
+
def active_sites(self) -> SiteQuerySet:
|
283
|
+
"""Get all active sites."""
|
284
|
+
return self.sites().active()
|
285
|
+
|
286
|
+
def maintenance_sites(self) -> SiteQuerySet:
|
287
|
+
"""Get sites currently in maintenance."""
|
288
|
+
return self.sites().in_maintenance()
|
289
|
+
|
290
|
+
def discover_and_create_sites(self,
|
291
|
+
api_key: CloudflareApiKey,
|
292
|
+
dry_run: bool = False) -> Dict[str, Any]:
|
293
|
+
"""Discover zones from Cloudflare and create sites."""
|
294
|
+
from .site_sync_service import SiteSyncService
|
295
|
+
|
296
|
+
try:
|
297
|
+
sync_service = SiteSyncService(api_key)
|
298
|
+
return sync_service.sync_zones(dry_run=dry_run)
|
299
|
+
except Exception as e:
|
300
|
+
logger.error(f"Failed to discover sites for {api_key.name}: {e}")
|
301
|
+
return {
|
302
|
+
'success': False,
|
303
|
+
'error': str(e),
|
304
|
+
'discovered': 0,
|
305
|
+
'created': 0,
|
306
|
+
'updated': 0,
|
307
|
+
'skipped': 0,
|
308
|
+
'errors': 1
|
309
|
+
}
|
310
|
+
|
311
|
+
def get_statistics(self) -> Dict[str, Any]:
|
312
|
+
"""Get overall statistics for all sites."""
|
313
|
+
all_sites = CloudflareSite.objects.all()
|
314
|
+
|
315
|
+
stats = {
|
316
|
+
'total_sites': all_sites.count(),
|
317
|
+
'active_sites': all_sites.filter(is_active=True).count(),
|
318
|
+
'maintenance_sites': all_sites.filter(maintenance_active=True).count(),
|
319
|
+
'api_keys_count': CloudflareApiKey.objects.filter(is_active=True).count(),
|
320
|
+
'recent_logs': MaintenanceLog.objects.count(),
|
321
|
+
'by_api_key': {}
|
322
|
+
}
|
323
|
+
|
324
|
+
# Statistics by API key
|
325
|
+
for api_key in CloudflareApiKey.objects.filter(is_active=True):
|
326
|
+
key_sites = all_sites.filter(api_key=api_key)
|
327
|
+
stats['by_api_key'][api_key.name] = {
|
328
|
+
'total': key_sites.count(),
|
329
|
+
'active': key_sites.filter(is_active=True).count(),
|
330
|
+
'maintenance': key_sites.filter(maintenance_active=True).count()
|
331
|
+
}
|
332
|
+
|
333
|
+
return stats
|
334
|
+
|
335
|
+
def emergency_disable_all(self,
|
336
|
+
reason: str = "Emergency maintenance disable",
|
337
|
+
max_workers: int = 10) -> Dict[str, Any]:
|
338
|
+
"""Emergency disable maintenance for all sites."""
|
339
|
+
logger.warning("Emergency disable maintenance for ALL sites")
|
340
|
+
|
341
|
+
return self.maintenance_sites().disable_maintenance(
|
342
|
+
max_workers=max_workers
|
343
|
+
)
|
344
|
+
|
345
|
+
def emergency_enable_all(self,
|
346
|
+
reason: str = "Emergency maintenance enable",
|
347
|
+
template: str = "minimal",
|
348
|
+
max_workers: int = 10) -> Dict[str, Any]:
|
349
|
+
"""Emergency enable maintenance for all sites."""
|
350
|
+
logger.warning("Emergency enable maintenance for ALL sites")
|
351
|
+
|
352
|
+
return self.active_sites().not_in_maintenance().enable_maintenance(
|
353
|
+
reason=reason,
|
354
|
+
template=template,
|
355
|
+
max_workers=max_workers
|
356
|
+
)
|
357
|
+
|
358
|
+
|
359
|
+
# Global instance
|
360
|
+
bulk_operations = BulkOperationsService()
|
361
|
+
|
362
|
+
|
363
|
+
# Convenience functions
|
364
|
+
def enable_maintenance_for_domains(domains: List[str],
|
365
|
+
reason: str = "Bulk operation",
|
366
|
+
template: str = "modern") -> Dict[str, Any]:
|
367
|
+
"""Enable maintenance for specific domains."""
|
368
|
+
sites = CloudflareSite.objects.filter(domain__in=domains)
|
369
|
+
return bulk_operations.sites(sites).enable_maintenance(reason=reason, template=template)
|
370
|
+
|
371
|
+
|
372
|
+
def disable_maintenance_for_domains(domains: List[str]) -> Dict[str, Any]:
|
373
|
+
"""Disable maintenance for specific domains."""
|
374
|
+
sites = CloudflareSite.objects.filter(domain__in=domains)
|
375
|
+
return bulk_operations.sites(sites).disable_maintenance()
|
376
|
+
|
377
|
+
|
378
|
+
def bulk_sync_all_sites() -> Dict[str, Any]:
|
379
|
+
"""Sync all sites from Cloudflare."""
|
380
|
+
return bulk_operations.all_sites().sync_from_cloudflare()
|
381
|
+
|
382
|
+
|
383
|
+
def get_maintenance_status_report() -> Dict[str, Any]:
|
384
|
+
"""Get comprehensive maintenance status report."""
|
385
|
+
stats = bulk_operations.get_statistics()
|
386
|
+
|
387
|
+
# Add recent activity
|
388
|
+
recent_logs = MaintenanceLog.objects.order_by('-created_at')[:10]
|
389
|
+
stats['recent_activity'] = [
|
390
|
+
{
|
391
|
+
'site': log.site.domain,
|
392
|
+
'action': log.get_action_display(),
|
393
|
+
'status': log.get_status_display(),
|
394
|
+
'created_at': log.created_at.isoformat(),
|
395
|
+
'duration': log.duration_seconds
|
396
|
+
}
|
397
|
+
for log in recent_logs
|
398
|
+
]
|
399
|
+
|
400
|
+
return stats
|
@@ -0,0 +1,230 @@
|
|
1
|
+
"""
|
2
|
+
Super simple maintenance service using Page Rules.
|
3
|
+
|
4
|
+
No Workers, no templates, no complexity - just Page Rules!
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
import time
|
9
|
+
from typing import Dict, Any
|
10
|
+
from cloudflare import Cloudflare
|
11
|
+
|
12
|
+
from ..models import CloudflareSite, MaintenanceLog
|
13
|
+
from ..utils import retry_on_failure
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
class MaintenanceService:
|
18
|
+
"""
|
19
|
+
Simple maintenance via Cloudflare Page Rules.
|
20
|
+
|
21
|
+
Enable: Create Page Rule redirect to maintenance.reforms.ai
|
22
|
+
Disable: Delete Page Rule
|
23
|
+
"""
|
24
|
+
|
25
|
+
def __init__(self, site: CloudflareSite):
|
26
|
+
"""Initialize service for specific site."""
|
27
|
+
self.site = site
|
28
|
+
self.client = Cloudflare(api_token=site.api_key.api_token)
|
29
|
+
|
30
|
+
def enable_maintenance(self, reason: str = "Scheduled maintenance") -> MaintenanceLog:
|
31
|
+
"""
|
32
|
+
Enable maintenance mode using Page Rule redirect.
|
33
|
+
|
34
|
+
Steps:
|
35
|
+
1. Create Page Rule redirect to maintenance.reforms.ai
|
36
|
+
2. Update site.maintenance_active = True
|
37
|
+
3. Log the operation
|
38
|
+
"""
|
39
|
+
start_time = time.time()
|
40
|
+
log_entry = MaintenanceLog.log_pending(self.site, MaintenanceLog.Action.ENABLE, reason)
|
41
|
+
|
42
|
+
try:
|
43
|
+
# 1. Create Page Rule for maintenance redirect
|
44
|
+
logger.info(f"Creating page rule for: {self.site.domain} → {self.site.get_maintenance_url()}")
|
45
|
+
page_rule_response = self._create_maintenance_page_rule()
|
46
|
+
|
47
|
+
# 2. Update site status
|
48
|
+
self.site.enable_maintenance()
|
49
|
+
|
50
|
+
# 3. Log success
|
51
|
+
duration = int(time.time() - start_time)
|
52
|
+
log_entry.status = MaintenanceLog.Status.SUCCESS
|
53
|
+
log_entry.duration_seconds = duration
|
54
|
+
log_entry.cloudflare_response = {
|
55
|
+
'page_rule_create': self._serialize_response(page_rule_response)
|
56
|
+
}
|
57
|
+
log_entry.save()
|
58
|
+
|
59
|
+
return log_entry
|
60
|
+
|
61
|
+
except Exception as e:
|
62
|
+
# Log failure
|
63
|
+
duration = int(time.time() - start_time)
|
64
|
+
log_entry.status = MaintenanceLog.Status.FAILED
|
65
|
+
log_entry.error_message = str(e)
|
66
|
+
log_entry.duration_seconds = duration
|
67
|
+
log_entry.save()
|
68
|
+
|
69
|
+
raise e
|
70
|
+
|
71
|
+
def disable_maintenance(self) -> MaintenanceLog:
|
72
|
+
"""
|
73
|
+
Disable maintenance mode by removing Page Rule.
|
74
|
+
|
75
|
+
Steps:
|
76
|
+
1. Remove Page Rule redirect
|
77
|
+
2. Update site.maintenance_active = False
|
78
|
+
3. Log the operation
|
79
|
+
"""
|
80
|
+
start_time = time.time()
|
81
|
+
log_entry = MaintenanceLog.log_pending(self.site, MaintenanceLog.Action.DISABLE)
|
82
|
+
|
83
|
+
try:
|
84
|
+
# 1. Remove Page Rule
|
85
|
+
logger.info(f"Removing page rule for: {self.site.domain}")
|
86
|
+
page_rule_response = self._delete_maintenance_page_rule()
|
87
|
+
|
88
|
+
# 2. Update site status
|
89
|
+
self.site.disable_maintenance()
|
90
|
+
|
91
|
+
# 3. Log success
|
92
|
+
duration = int(time.time() - start_time)
|
93
|
+
log_entry.status = MaintenanceLog.Status.SUCCESS
|
94
|
+
log_entry.duration_seconds = duration
|
95
|
+
log_entry.cloudflare_response = {
|
96
|
+
'page_rule_delete': self._serialize_response(page_rule_response)
|
97
|
+
}
|
98
|
+
log_entry.save()
|
99
|
+
|
100
|
+
return log_entry
|
101
|
+
|
102
|
+
except Exception as e:
|
103
|
+
# Log failure
|
104
|
+
duration = int(time.time() - start_time)
|
105
|
+
log_entry.status = MaintenanceLog.Status.FAILED
|
106
|
+
log_entry.error_message = str(e)
|
107
|
+
log_entry.duration_seconds = duration
|
108
|
+
log_entry.save()
|
109
|
+
|
110
|
+
raise e
|
111
|
+
|
112
|
+
def get_status(self) -> bool:
|
113
|
+
"""Get current maintenance status for site."""
|
114
|
+
return self.site.maintenance_active
|
115
|
+
|
116
|
+
# Private helper methods
|
117
|
+
|
118
|
+
def _serialize_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
119
|
+
"""Convert Cloudflare API response to JSON serializable format."""
|
120
|
+
import json
|
121
|
+
from datetime import datetime
|
122
|
+
|
123
|
+
def convert_datetime(obj):
|
124
|
+
if isinstance(obj, datetime):
|
125
|
+
return obj.isoformat()
|
126
|
+
elif isinstance(obj, dict):
|
127
|
+
return {k: convert_datetime(v) for k, v in obj.items()}
|
128
|
+
elif isinstance(obj, list):
|
129
|
+
return [convert_datetime(item) for item in obj]
|
130
|
+
else:
|
131
|
+
return obj
|
132
|
+
|
133
|
+
try:
|
134
|
+
# Convert all datetime objects to ISO strings
|
135
|
+
serializable = convert_datetime(response)
|
136
|
+
# Test JSON serialization
|
137
|
+
json.dumps(serializable)
|
138
|
+
return serializable
|
139
|
+
except Exception:
|
140
|
+
# If serialization fails, return simple success message
|
141
|
+
return {"success": True, "serialization_error": True}
|
142
|
+
|
143
|
+
@retry_on_failure(max_retries=3, base_delay=1.0)
|
144
|
+
def _create_maintenance_page_rule(self) -> Dict[str, Any]:
|
145
|
+
"""Create Page Rule to redirect all traffic to maintenance page."""
|
146
|
+
maintenance_url = self.site.get_maintenance_url()
|
147
|
+
pattern = f"{self.site.domain}/*"
|
148
|
+
|
149
|
+
logger.info(f"Creating page rule: {pattern} → {maintenance_url}")
|
150
|
+
|
151
|
+
response = self.client.page_rules.create(
|
152
|
+
zone_id=self.site.zone_id,
|
153
|
+
targets=[{
|
154
|
+
"target": "url",
|
155
|
+
"constraint": {
|
156
|
+
"operator": "matches",
|
157
|
+
"value": pattern
|
158
|
+
}
|
159
|
+
}],
|
160
|
+
actions=[{
|
161
|
+
"id": "forwarding_url",
|
162
|
+
"value": {
|
163
|
+
"url": maintenance_url,
|
164
|
+
"status_code": 302
|
165
|
+
}
|
166
|
+
}],
|
167
|
+
status="active"
|
168
|
+
)
|
169
|
+
return response.model_dump()
|
170
|
+
|
171
|
+
@retry_on_failure(max_retries=3, base_delay=1.0)
|
172
|
+
def _delete_maintenance_page_rule(self) -> Dict[str, Any]:
|
173
|
+
"""Delete maintenance Page Rule with retry logic."""
|
174
|
+
# Find the maintenance page rule
|
175
|
+
page_rules_response = self.client.page_rules.list(zone_id=self.site.zone_id)
|
176
|
+
|
177
|
+
# Handle different API response formats
|
178
|
+
if hasattr(page_rules_response, 'result'):
|
179
|
+
page_rules = page_rules_response.result
|
180
|
+
else:
|
181
|
+
page_rules = page_rules_response
|
182
|
+
|
183
|
+
maintenance_pattern = f"{self.site.domain}/*"
|
184
|
+
maintenance_url = self.site.get_maintenance_url()
|
185
|
+
|
186
|
+
logger.info(f"Looking for page rule to delete: pattern={maintenance_pattern}, url={maintenance_url}")
|
187
|
+
logger.info(f"Found {len(page_rules)} page rules total")
|
188
|
+
|
189
|
+
for rule in page_rules:
|
190
|
+
logger.info(f"Checking rule {rule.id}: targets={getattr(rule, 'targets', None)}, actions={getattr(rule, 'actions', None)}")
|
191
|
+
|
192
|
+
# Simple check - look for forwarding_url action with maintenance URL
|
193
|
+
if (hasattr(rule, 'actions') and rule.actions and
|
194
|
+
len(rule.actions) > 0 and
|
195
|
+
hasattr(rule.actions[0], 'id') and
|
196
|
+
rule.actions[0].id == "forwarding_url"):
|
197
|
+
|
198
|
+
# Check if URL contains maintenance.reforms.ai
|
199
|
+
action_value = getattr(rule.actions[0], 'value', {})
|
200
|
+
action_url = getattr(action_value, 'url', '')
|
201
|
+
|
202
|
+
logger.info(f"Found forwarding rule with URL: {action_url}")
|
203
|
+
|
204
|
+
if "maintenance.reforms.ai" in action_url:
|
205
|
+
logger.info(f"Deleting maintenance page rule: {rule.id}")
|
206
|
+
response = self.client.page_rules.delete(
|
207
|
+
zone_id=self.site.zone_id,
|
208
|
+
pagerule_id=rule.id
|
209
|
+
)
|
210
|
+
return response.model_dump()
|
211
|
+
|
212
|
+
logger.warning(f"No maintenance page rule found for {self.site.domain}")
|
213
|
+
return {"success": True, "message": "No page rule to delete"}
|
214
|
+
|
215
|
+
|
216
|
+
|
217
|
+
# Convenience functions for easy usage
|
218
|
+
|
219
|
+
def enable_maintenance_for_domain(domain: str, reason: str = "Scheduled maintenance") -> MaintenanceLog:
|
220
|
+
"""Enable maintenance for a domain."""
|
221
|
+
site = CloudflareSite.objects.get(domain=domain)
|
222
|
+
service = MaintenanceService(site)
|
223
|
+
return service.enable_maintenance(reason)
|
224
|
+
|
225
|
+
|
226
|
+
def disable_maintenance_for_domain(domain: str) -> MaintenanceLog:
|
227
|
+
"""Disable maintenance for a domain."""
|
228
|
+
site = CloudflareSite.objects.get(domain=domain)
|
229
|
+
service = MaintenanceService(site)
|
230
|
+
return service.disable_maintenance()
|