django-cfg 1.2.17__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.17.dist-info → django_cfg-1.2.18.dist-info}/METADATA +52 -1
- {django_cfg-1.2.17.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.17.dist-info → django_cfg-1.2.18.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.18.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.17.dist-info → django_cfg-1.2.18.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,390 @@
|
|
1
|
+
"""
|
2
|
+
Site synchronization service for automatic Cloudflare zone discovery.
|
3
|
+
|
4
|
+
Automatically discovers and syncs Cloudflare zones with Django models.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
from typing import Dict, List, Any, Optional
|
9
|
+
from datetime import datetime
|
10
|
+
from django.utils import timezone
|
11
|
+
from django.db import transaction
|
12
|
+
|
13
|
+
from ..models import CloudflareApiKey, CloudflareSite, MaintenanceLog
|
14
|
+
from .maintenance_service import MaintenanceService
|
15
|
+
from ..utils.retry_utils import retry_on_failure, CloudflareRetryError
|
16
|
+
|
17
|
+
try:
|
18
|
+
from cloudflare import Cloudflare
|
19
|
+
except ImportError:
|
20
|
+
raise ImportError("cloudflare library is required. Install with: pip install cloudflare>=4.3.0")
|
21
|
+
|
22
|
+
logger = logging.getLogger(__name__)
|
23
|
+
|
24
|
+
|
25
|
+
class SiteSyncService:
|
26
|
+
"""
|
27
|
+
Service for synchronizing CloudflareSite models with actual Cloudflare zones.
|
28
|
+
|
29
|
+
Provides automatic discovery and sync of Cloudflare zones.
|
30
|
+
"""
|
31
|
+
|
32
|
+
def __init__(self, api_key: CloudflareApiKey):
|
33
|
+
"""Initialize sync service with API key."""
|
34
|
+
self.api_key = api_key
|
35
|
+
self.client = Cloudflare(api_token=api_key.api_token)
|
36
|
+
|
37
|
+
@retry_on_failure(max_retries=3)
|
38
|
+
def discover_zones(self) -> List[Dict[str, Any]]:
|
39
|
+
"""
|
40
|
+
Discover all zones in the Cloudflare account.
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
List of zone data dictionaries
|
44
|
+
"""
|
45
|
+
logger.info(f"Discovering zones for API key: {self.api_key.name}")
|
46
|
+
|
47
|
+
try:
|
48
|
+
zones = []
|
49
|
+
for zone in self.client.zones.list():
|
50
|
+
zone_data = {
|
51
|
+
'id': zone.id,
|
52
|
+
'name': zone.name,
|
53
|
+
'account_id': zone.account.id if zone.account else None,
|
54
|
+
'status': zone.status,
|
55
|
+
'paused': zone.paused,
|
56
|
+
'type': zone.type,
|
57
|
+
'development_mode': zone.development_mode,
|
58
|
+
'name_servers': zone.name_servers,
|
59
|
+
'created_on': zone.created_on,
|
60
|
+
'modified_on': zone.modified_on,
|
61
|
+
}
|
62
|
+
zones.append(zone_data)
|
63
|
+
|
64
|
+
logger.info(f"Discovered {len(zones)} zones")
|
65
|
+
return zones
|
66
|
+
|
67
|
+
except Exception as e:
|
68
|
+
logger.error(f"Failed to discover zones: {e}")
|
69
|
+
raise CloudflareRetryError(f"Zone discovery failed: {e}")
|
70
|
+
|
71
|
+
def sync_zones(self,
|
72
|
+
force_update: bool = False,
|
73
|
+
dry_run: bool = False) -> Dict[str, Any]:
|
74
|
+
"""
|
75
|
+
Sync discovered zones with Django models.
|
76
|
+
|
77
|
+
Args:
|
78
|
+
force_update: Update existing sites even if they haven't changed
|
79
|
+
dry_run: Only show what would be changed without making changes
|
80
|
+
|
81
|
+
Returns:
|
82
|
+
Dict with sync statistics and results
|
83
|
+
"""
|
84
|
+
logger.info(f"Starting zone sync (dry_run={dry_run}, force_update={force_update})")
|
85
|
+
|
86
|
+
stats = {
|
87
|
+
'discovered': 0,
|
88
|
+
'created': 0,
|
89
|
+
'updated': 0,
|
90
|
+
'skipped': 0,
|
91
|
+
'errors': 0,
|
92
|
+
'sites': []
|
93
|
+
}
|
94
|
+
|
95
|
+
try:
|
96
|
+
# Discover zones from Cloudflare
|
97
|
+
cf_zones = self.discover_zones()
|
98
|
+
stats['discovered'] = len(cf_zones)
|
99
|
+
|
100
|
+
# Get existing sites for this API key
|
101
|
+
existing_sites = {
|
102
|
+
site.zone_id: site
|
103
|
+
for site in CloudflareSite.objects.filter(api_key=self.api_key)
|
104
|
+
}
|
105
|
+
|
106
|
+
# Process each discovered zone
|
107
|
+
for zone_data in cf_zones:
|
108
|
+
try:
|
109
|
+
result = self._sync_single_zone(
|
110
|
+
zone_data=zone_data,
|
111
|
+
existing_sites=existing_sites,
|
112
|
+
force_update=force_update,
|
113
|
+
dry_run=dry_run
|
114
|
+
)
|
115
|
+
|
116
|
+
stats[result['action']] += 1
|
117
|
+
stats['sites'].append(result)
|
118
|
+
|
119
|
+
except Exception as e:
|
120
|
+
logger.error(f"Error syncing zone {zone_data['name']}: {e}")
|
121
|
+
stats['errors'] += 1
|
122
|
+
stats['sites'].append({
|
123
|
+
'domain': zone_data['name'],
|
124
|
+
'action': 'error',
|
125
|
+
'error': str(e)
|
126
|
+
})
|
127
|
+
|
128
|
+
# Mark API key as used
|
129
|
+
self.api_key.mark_used()
|
130
|
+
|
131
|
+
logger.info(f"Zone sync completed: {stats}")
|
132
|
+
return stats
|
133
|
+
|
134
|
+
except Exception as e:
|
135
|
+
logger.error(f"Zone sync failed: {e}")
|
136
|
+
stats['errors'] += 1
|
137
|
+
return stats
|
138
|
+
|
139
|
+
def _sync_single_zone(self,
|
140
|
+
zone_data: Dict[str, Any],
|
141
|
+
existing_sites: Dict[str, CloudflareSite],
|
142
|
+
force_update: bool,
|
143
|
+
dry_run: bool) -> Dict[str, Any]:
|
144
|
+
"""Sync a single zone with Django model."""
|
145
|
+
zone_id = zone_data['id']
|
146
|
+
domain = zone_data['name']
|
147
|
+
|
148
|
+
if zone_id in existing_sites:
|
149
|
+
# Site exists - check if update needed
|
150
|
+
site = existing_sites[zone_id]
|
151
|
+
|
152
|
+
needs_update = (
|
153
|
+
force_update or
|
154
|
+
site.domain != domain or
|
155
|
+
site.account_id != zone_data.get('account_id')
|
156
|
+
)
|
157
|
+
|
158
|
+
if needs_update:
|
159
|
+
if dry_run:
|
160
|
+
return {
|
161
|
+
'domain': domain,
|
162
|
+
'action': 'would_update',
|
163
|
+
'changes': self._get_site_changes(site, zone_data)
|
164
|
+
}
|
165
|
+
else:
|
166
|
+
# Update existing site
|
167
|
+
site.domain = domain
|
168
|
+
site.account_id = zone_data.get('account_id', site.account_id)
|
169
|
+
site.save()
|
170
|
+
|
171
|
+
# Log the sync
|
172
|
+
MaintenanceLog.log_success(
|
173
|
+
site=site,
|
174
|
+
action=MaintenanceLog.Action.SYNC,
|
175
|
+
reason="Automatic zone sync - updated existing site"
|
176
|
+
)
|
177
|
+
|
178
|
+
return {
|
179
|
+
'domain': domain,
|
180
|
+
'action': 'updated',
|
181
|
+
'site_id': site.id
|
182
|
+
}
|
183
|
+
else:
|
184
|
+
return {
|
185
|
+
'domain': domain,
|
186
|
+
'action': 'skipped',
|
187
|
+
'reason': 'No changes needed'
|
188
|
+
}
|
189
|
+
else:
|
190
|
+
# New site - create it
|
191
|
+
if dry_run:
|
192
|
+
return {
|
193
|
+
'domain': domain,
|
194
|
+
'action': 'would_create',
|
195
|
+
'zone_data': zone_data
|
196
|
+
}
|
197
|
+
else:
|
198
|
+
# Create new site
|
199
|
+
site = CloudflareSite.objects.create(
|
200
|
+
name=self._generate_site_name(domain),
|
201
|
+
domain=domain,
|
202
|
+
zone_id=zone_id,
|
203
|
+
account_id=zone_data.get('account_id', ''),
|
204
|
+
api_key=self.api_key,
|
205
|
+
is_active=not zone_data.get('paused', False)
|
206
|
+
)
|
207
|
+
|
208
|
+
# Log the creation
|
209
|
+
MaintenanceLog.log_success(
|
210
|
+
site=site,
|
211
|
+
action=MaintenanceLog.Action.SYNC,
|
212
|
+
reason="Automatic zone sync - created new site"
|
213
|
+
)
|
214
|
+
|
215
|
+
return {
|
216
|
+
'domain': domain,
|
217
|
+
'action': 'created',
|
218
|
+
'site_id': site.id
|
219
|
+
}
|
220
|
+
|
221
|
+
def _get_site_changes(self, site: CloudflareSite, zone_data: Dict[str, Any]) -> Dict[str, Any]:
|
222
|
+
"""Get changes that would be made to a site."""
|
223
|
+
changes = {}
|
224
|
+
|
225
|
+
if site.domain != zone_data['name']:
|
226
|
+
changes['domain'] = {
|
227
|
+
'old': site.domain,
|
228
|
+
'new': zone_data['name']
|
229
|
+
}
|
230
|
+
|
231
|
+
if site.account_id != zone_data.get('account_id'):
|
232
|
+
changes['account_id'] = {
|
233
|
+
'old': site.account_id,
|
234
|
+
'new': zone_data.get('account_id')
|
235
|
+
}
|
236
|
+
|
237
|
+
return changes
|
238
|
+
|
239
|
+
def _generate_site_name(self, domain: str) -> str:
|
240
|
+
"""Generate a friendly site name from domain."""
|
241
|
+
# Remove common prefixes and TLD for cleaner name
|
242
|
+
name = domain.replace('www.', '').replace('api.', '').replace('app.', '')
|
243
|
+
|
244
|
+
# Capitalize first letter
|
245
|
+
if '.' in name:
|
246
|
+
name = name.split('.')[0].capitalize()
|
247
|
+
else:
|
248
|
+
name = name.capitalize()
|
249
|
+
|
250
|
+
return name
|
251
|
+
|
252
|
+
def check_site_status(self, site: CloudflareSite) -> Dict[str, Any]:
|
253
|
+
"""
|
254
|
+
Check current status of a site in Cloudflare.
|
255
|
+
|
256
|
+
Args:
|
257
|
+
site: CloudflareSite to check
|
258
|
+
|
259
|
+
Returns:
|
260
|
+
Dict with status information
|
261
|
+
"""
|
262
|
+
try:
|
263
|
+
# Get zone info
|
264
|
+
zone = self.client.zones.get(zone_id=site.zone_id)
|
265
|
+
|
266
|
+
# Check if maintenance worker is active
|
267
|
+
maintenance_active = self._check_maintenance_worker(site)
|
268
|
+
|
269
|
+
status_info = {
|
270
|
+
'zone_status': zone.status,
|
271
|
+
'zone_paused': zone.paused,
|
272
|
+
'development_mode': zone.development_mode,
|
273
|
+
'maintenance_active': maintenance_active,
|
274
|
+
'last_checked': timezone.now().isoformat()
|
275
|
+
}
|
276
|
+
|
277
|
+
# Update site status
|
278
|
+
site.maintenance_active = maintenance_active
|
279
|
+
site.is_active = not zone.paused
|
280
|
+
site.save()
|
281
|
+
|
282
|
+
return status_info
|
283
|
+
|
284
|
+
except Exception as e:
|
285
|
+
logger.error(f"Failed to check status for {site.domain}: {e}")
|
286
|
+
return {
|
287
|
+
'error': str(e),
|
288
|
+
'last_checked': timezone.now().isoformat()
|
289
|
+
}
|
290
|
+
|
291
|
+
@retry_on_failure(max_retries=2)
|
292
|
+
def _check_maintenance_worker(self, site: CloudflareSite) -> bool:
|
293
|
+
"""Check if maintenance worker is active for site."""
|
294
|
+
try:
|
295
|
+
# List worker routes for the zone
|
296
|
+
routes = self.client.workers.routes.list(zone_id=site.zone_id)
|
297
|
+
|
298
|
+
# Check if any route matches our maintenance worker pattern
|
299
|
+
maintenance_service = MaintenanceService(site)
|
300
|
+
worker_name = maintenance_service.worker_name
|
301
|
+
|
302
|
+
for route in routes.result:
|
303
|
+
if hasattr(route, 'script') and route.script == worker_name:
|
304
|
+
return True
|
305
|
+
|
306
|
+
return False
|
307
|
+
|
308
|
+
except Exception as e:
|
309
|
+
logger.warning(f"Could not check maintenance worker for {site.domain}: {e}")
|
310
|
+
return False
|
311
|
+
|
312
|
+
def bulk_sync_all_api_keys(self) -> Dict[str, Any]:
|
313
|
+
"""
|
314
|
+
Sync zones for all active API keys.
|
315
|
+
|
316
|
+
Returns:
|
317
|
+
Dict with overall sync results
|
318
|
+
"""
|
319
|
+
logger.info("Starting bulk sync for all API keys")
|
320
|
+
|
321
|
+
overall_stats = {
|
322
|
+
'api_keys_processed': 0,
|
323
|
+
'total_discovered': 0,
|
324
|
+
'total_created': 0,
|
325
|
+
'total_updated': 0,
|
326
|
+
'total_skipped': 0,
|
327
|
+
'total_errors': 0,
|
328
|
+
'api_key_results': []
|
329
|
+
}
|
330
|
+
|
331
|
+
active_api_keys = CloudflareApiKey.objects.filter(is_active=True)
|
332
|
+
|
333
|
+
for api_key in active_api_keys:
|
334
|
+
try:
|
335
|
+
sync_service = SiteSyncService(api_key)
|
336
|
+
result = sync_service.sync_zones()
|
337
|
+
|
338
|
+
overall_stats['api_keys_processed'] += 1
|
339
|
+
overall_stats['total_discovered'] += result['discovered']
|
340
|
+
overall_stats['total_created'] += result['created']
|
341
|
+
overall_stats['total_updated'] += result['updated']
|
342
|
+
overall_stats['total_skipped'] += result['skipped']
|
343
|
+
overall_stats['total_errors'] += result['errors']
|
344
|
+
|
345
|
+
overall_stats['api_key_results'].append({
|
346
|
+
'api_key_name': api_key.name,
|
347
|
+
'success': True,
|
348
|
+
'stats': result
|
349
|
+
})
|
350
|
+
|
351
|
+
except Exception as e:
|
352
|
+
logger.error(f"Failed to sync API key {api_key.name}: {e}")
|
353
|
+
overall_stats['total_errors'] += 1
|
354
|
+
overall_stats['api_key_results'].append({
|
355
|
+
'api_key_name': api_key.name,
|
356
|
+
'success': False,
|
357
|
+
'error': str(e)
|
358
|
+
})
|
359
|
+
|
360
|
+
logger.info(f"Bulk sync completed: {overall_stats}")
|
361
|
+
return overall_stats
|
362
|
+
|
363
|
+
|
364
|
+
def sync_site_from_cloudflare(site: CloudflareSite) -> MaintenanceLog:
|
365
|
+
"""
|
366
|
+
Convenience function to sync a single site from Cloudflare.
|
367
|
+
|
368
|
+
Args:
|
369
|
+
site: CloudflareSite to sync
|
370
|
+
|
371
|
+
Returns:
|
372
|
+
MaintenanceLog entry for the sync operation
|
373
|
+
"""
|
374
|
+
try:
|
375
|
+
sync_service = SiteSyncService(site.api_key)
|
376
|
+
status_info = sync_service.check_site_status(site)
|
377
|
+
|
378
|
+
return MaintenanceLog.log_success(
|
379
|
+
site=site,
|
380
|
+
action=MaintenanceLog.Action.SYNC,
|
381
|
+
reason="Manual site sync",
|
382
|
+
cloudflare_response=status_info
|
383
|
+
)
|
384
|
+
|
385
|
+
except Exception as e:
|
386
|
+
return MaintenanceLog.log_failure(
|
387
|
+
site=site,
|
388
|
+
action=MaintenanceLog.Action.SYNC,
|
389
|
+
error_message=str(e)
|
390
|
+
)
|
@@ -0,0 +1,109 @@
|
|
1
|
+
"""
|
2
|
+
Retry utilities for Cloudflare operations.
|
3
|
+
|
4
|
+
Simple retry logic extracted from the complex old system.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import time
|
8
|
+
import random
|
9
|
+
import logging
|
10
|
+
from typing import Callable, Any, List
|
11
|
+
from functools import wraps
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
class CloudflareRetryError(Exception):
|
17
|
+
"""Exception raised when all retry attempts fail."""
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
def retry_on_failure(
|
22
|
+
max_retries: int = 3,
|
23
|
+
base_delay: float = 1.0,
|
24
|
+
max_delay: float = 30.0,
|
25
|
+
backoff_factor: float = 2.0,
|
26
|
+
retry_on_status: List[int] = None,
|
27
|
+
jitter: bool = True
|
28
|
+
):
|
29
|
+
"""
|
30
|
+
Decorator for retrying Cloudflare operations with exponential backoff.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
max_retries: Maximum number of retry attempts
|
34
|
+
base_delay: Initial delay between retries in seconds
|
35
|
+
max_delay: Maximum delay between retries in seconds
|
36
|
+
backoff_factor: Factor to multiply delay by after each retry
|
37
|
+
retry_on_status: HTTP status codes to retry on (deprecated, kept for compatibility)
|
38
|
+
jitter: Whether to add random jitter to delays
|
39
|
+
"""
|
40
|
+
if retry_on_status is None:
|
41
|
+
retry_on_status = [429, 502, 503, 504]
|
42
|
+
|
43
|
+
def decorator(func: Callable) -> Callable:
|
44
|
+
@wraps(func)
|
45
|
+
def wrapper(*args, **kwargs) -> Any:
|
46
|
+
last_exception = None
|
47
|
+
|
48
|
+
for attempt in range(max_retries + 1):
|
49
|
+
try:
|
50
|
+
return func(*args, **kwargs)
|
51
|
+
|
52
|
+
except Exception as e:
|
53
|
+
last_exception = e
|
54
|
+
|
55
|
+
# Don't retry on the last attempt
|
56
|
+
if attempt == max_retries:
|
57
|
+
break
|
58
|
+
|
59
|
+
# Check if we should retry based on the error
|
60
|
+
should_retry = True # Default to retry
|
61
|
+
|
62
|
+
# Check for HTTP status codes in the error message
|
63
|
+
error_str = str(e).lower()
|
64
|
+
|
65
|
+
# Check if any custom retry status codes are in the error
|
66
|
+
has_custom_status = False
|
67
|
+
for status in retry_on_status:
|
68
|
+
if str(status) in error_str:
|
69
|
+
has_custom_status = True
|
70
|
+
break
|
71
|
+
|
72
|
+
# If we have custom status codes and found one, always retry
|
73
|
+
if has_custom_status:
|
74
|
+
should_retry = True
|
75
|
+
else:
|
76
|
+
# Don't retry on certain non-retryable errors (only if no custom status found)
|
77
|
+
if any(keyword in error_str for keyword in [
|
78
|
+
'authentication', 'unauthorized', 'forbidden', 'not found',
|
79
|
+
'invalid', 'malformed'
|
80
|
+
]):
|
81
|
+
should_retry = False
|
82
|
+
|
83
|
+
if not should_retry:
|
84
|
+
# Don't retry on non-retryable errors
|
85
|
+
break
|
86
|
+
|
87
|
+
# Calculate delay with exponential backoff and optional jitter
|
88
|
+
delay = min(base_delay * (backoff_factor ** attempt), max_delay)
|
89
|
+
if jitter:
|
90
|
+
jitter_amount = random.uniform(0.1, 0.3) * delay
|
91
|
+
total_delay = delay + jitter_amount
|
92
|
+
else:
|
93
|
+
total_delay = delay
|
94
|
+
|
95
|
+
logger.warning(
|
96
|
+
f"Attempt {attempt + 1}/{max_retries + 1} failed for {func.__name__}: {e}. "
|
97
|
+
f"Retrying in {total_delay:.2f}s..."
|
98
|
+
)
|
99
|
+
|
100
|
+
time.sleep(total_delay)
|
101
|
+
|
102
|
+
# All retries failed
|
103
|
+
raise CloudflareRetryError(
|
104
|
+
f"All {max_retries + 1} attempts failed for {func.__name__}. "
|
105
|
+
f"Last error: {last_exception}"
|
106
|
+
) from last_exception
|
107
|
+
|
108
|
+
return wrapper
|
109
|
+
return decorator
|
django_cfg/config.py
CHANGED
@@ -17,6 +17,9 @@ LIB_GITHUB_URL = "https://github.com/django-cfg/django-cfg"
|
|
17
17
|
LIB_SUPPORT_URL = "https://djangocfg.com/support"
|
18
18
|
LIB_HEALTH_URL = "/cfg/health/"
|
19
19
|
|
20
|
+
def get_maintenance_url(domain: str) -> str:
|
21
|
+
"""Get the maintenance URL for the current site."""
|
22
|
+
return f"{LIB_SITE_URL}/maintenance/{domain}/"
|
20
23
|
|
21
24
|
def get_default_dropdown_items() -> List[SiteDropdownItem]:
|
22
25
|
"""Get default dropdown menu items for Unfold admin."""
|
django_cfg/core/config.py
CHANGED
@@ -154,6 +154,10 @@ class DjangoConfig(BaseModel):
|
|
154
154
|
default=False,
|
155
155
|
description="Enable django-cfg AI Agents application (agent definitions, executions, workflows, tools)",
|
156
156
|
)
|
157
|
+
enable_maintenance: bool = Field(
|
158
|
+
default=False,
|
159
|
+
description="Enable django-cfg Maintenance application (multi-site maintenance mode with Cloudflare)",
|
160
|
+
)
|
157
161
|
|
158
162
|
# === URLs ===
|
159
163
|
site_url: str = Field(default="http://localhost:3000", description="Frontend site URL")
|
@@ -270,12 +274,6 @@ class DjangoConfig(BaseModel):
|
|
270
274
|
description="Application limits configuration (file uploads, requests, etc.)",
|
271
275
|
)
|
272
276
|
|
273
|
-
# === Maintenance Mode Configuration ===
|
274
|
-
enable_maintenance: bool = Field(
|
275
|
-
default=False,
|
276
|
-
description="Enable django-cfg Maintenance application (multi-site maintenance mode with Cloudflare)",
|
277
|
-
)
|
278
|
-
|
279
277
|
# === Middleware Configuration ===
|
280
278
|
custom_middleware: List[str] = Field(
|
281
279
|
default_factory=list,
|
@@ -156,11 +156,10 @@ class DashboardManager(BaseCfgModule):
|
|
156
156
|
separator=True,
|
157
157
|
collapsible=True,
|
158
158
|
items=[
|
159
|
-
NavigationItem(title="Cloudflare Sites", icon=Icons.CLOUD, link="/admin/
|
160
|
-
NavigationItem(title="
|
161
|
-
NavigationItem(title="Maintenance
|
162
|
-
NavigationItem(title="
|
163
|
-
NavigationItem(title="Deployments", icon=Icons.ROCKET_LAUNCH, link="/admin/django_cfg_maintenance/cloudflaredeployment/"),
|
159
|
+
NavigationItem(title="Cloudflare Sites", icon=Icons.CLOUD, link="/admin/maintenance/cloudflaresite/"),
|
160
|
+
NavigationItem(title="API Keys", icon=Icons.KEY, link="/admin/maintenance/cloudflareapikey/"),
|
161
|
+
NavigationItem(title="Maintenance Logs", icon=Icons.HISTORY, link="/admin/maintenance/maintenancelog/"),
|
162
|
+
NavigationItem(title="Scheduled Maintenance", icon=Icons.SCHEDULE, link="/admin/maintenance/scheduledmaintenance/"),
|
164
163
|
]
|
165
164
|
))
|
166
165
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: django-cfg
|
3
|
-
Version: 1.2.
|
3
|
+
Version: 1.2.18
|
4
4
|
Summary: 🚀 Next-gen Django configuration: type-safety, AI features, blazing-fast setup, and automated best practices — all in one.
|
5
5
|
Project-URL: Homepage, https://djangocfg.com
|
6
6
|
Project-URL: Documentation, https://docs.djangocfg.com
|
@@ -264,6 +264,7 @@ class EnterpriseConfig(DjangoConfig):
|
|
264
264
|
enable_leads: bool = True # CRM integration
|
265
265
|
enable_agents: bool = True # AI workflow automation
|
266
266
|
enable_knowbase: bool = True # AI knowledge management
|
267
|
+
enable_maintenance: bool = True # Multi-site Cloudflare maintenance
|
267
268
|
|
268
269
|
config = EnterpriseConfig()
|
269
270
|
```
|
@@ -315,6 +316,34 @@ workflow = Workflow([
|
|
315
316
|
result = workflow.run({"document_id": "doc_123"})
|
316
317
|
```
|
317
318
|
|
319
|
+
### 🌐 **Multi-Site Cloudflare Maintenance**
|
320
|
+
**Zero-configuration maintenance mode** for enterprise applications with automated monitoring.
|
321
|
+
|
322
|
+
```python
|
323
|
+
from django_cfg.apps.maintenance.services import MaintenanceManager
|
324
|
+
|
325
|
+
# Enable maintenance for all production sites
|
326
|
+
manager = MaintenanceManager(user)
|
327
|
+
manager.bulk_enable_maintenance(
|
328
|
+
sites=CloudflareSite.objects.filter(environment='production'),
|
329
|
+
reason="Database migration",
|
330
|
+
message="🚀 Upgrading our systems. Back online in 30 minutes!"
|
331
|
+
)
|
332
|
+
|
333
|
+
# CLI management
|
334
|
+
# python manage.py maintenance enable --environment production
|
335
|
+
# python manage.py sync_cloudflare --api-token your_token
|
336
|
+
```
|
337
|
+
|
338
|
+
**Features:**
|
339
|
+
- ✅ **Zero-config setup** - Just provide API token and domain
|
340
|
+
- ✅ **Multi-site management** - Handle hundreds of sites with ORM queries
|
341
|
+
- ✅ **Automated monitoring** - Health checks with auto-triggers
|
342
|
+
- ✅ **Rich admin interface** - Bulk operations and real-time status
|
343
|
+
- ✅ **CLI automation** - Perfect for CI/CD pipelines
|
344
|
+
|
345
|
+
[**📚 View Maintenance Documentation →**](https://docs.djangocfg.com/features/built-in-apps/maintenance)
|
346
|
+
|
318
347
|
### 🏢 **Enterprise User Management**
|
319
348
|
**Multi-channel authentication** with OTP, SMS, email verification, and audit trails.
|
320
349
|
|
@@ -531,6 +560,7 @@ docker-compose up -d
|
|
531
560
|
|
532
561
|
### **🚀 Enterprise Features**
|
533
562
|
- [**Built-in Applications**](https://docs.djangocfg.com/features/built-in-apps/accounts) - User management, support, CRM
|
563
|
+
- [**Maintenance Management**](https://docs.djangocfg.com/features/built-in-apps/maintenance) - Multi-site Cloudflare maintenance
|
534
564
|
- [**Modular System**](https://docs.djangocfg.com/features/modules/overview) - Email, SMS, LLM, currency modules
|
535
565
|
- [**Third-party Integrations**](https://docs.djangocfg.com/features/integrations/patterns) - Dramatiq, Twilio, ngrok
|
536
566
|
|
@@ -749,6 +779,27 @@ python manage.py generate_deployment --platform=docker --environment=production
|
|
749
779
|
curl http://localhost:8000/health/
|
750
780
|
```
|
751
781
|
|
782
|
+
### **🌐 Multi-Site Maintenance Management**
|
783
|
+
```bash
|
784
|
+
# Enable maintenance for all production sites
|
785
|
+
python manage.py maintenance enable --environment production --reason "Database upgrade"
|
786
|
+
|
787
|
+
# Disable maintenance for specific project
|
788
|
+
python manage.py maintenance disable --project ecommerce
|
789
|
+
|
790
|
+
# Check status of all sites
|
791
|
+
python manage.py maintenance status --format json
|
792
|
+
|
793
|
+
# Auto-discover sites from Cloudflare
|
794
|
+
python manage.py sync_cloudflare --api-token your_token
|
795
|
+
|
796
|
+
# Bulk operations with filters
|
797
|
+
python manage.py maintenance enable --tag critical --reason "Security patch"
|
798
|
+
|
799
|
+
# Dry run to preview changes
|
800
|
+
python manage.py maintenance enable --environment staging --dry-run
|
801
|
+
```
|
802
|
+
|
752
803
|
---
|
753
804
|
|
754
805
|
## 🔒 Enterprise Security & Compliance
|