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.
Files changed (81) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/accounts/models/__init__.py +68 -0
  3. django_cfg/apps/accounts/models/activity.py +34 -0
  4. django_cfg/apps/accounts/models/auth.py +50 -0
  5. django_cfg/apps/accounts/models/base.py +8 -0
  6. django_cfg/apps/accounts/models/choices.py +32 -0
  7. django_cfg/apps/accounts/models/integrations.py +75 -0
  8. django_cfg/apps/accounts/models/registration.py +52 -0
  9. django_cfg/apps/accounts/models/user.py +80 -0
  10. django_cfg/apps/maintenance/__init__.py +53 -24
  11. django_cfg/apps/maintenance/admin/__init__.py +7 -18
  12. django_cfg/apps/maintenance/admin/api_key_admin.py +185 -0
  13. django_cfg/apps/maintenance/admin/log_admin.py +156 -0
  14. django_cfg/apps/maintenance/admin/scheduled_admin.py +390 -0
  15. django_cfg/apps/maintenance/admin/site_admin.py +448 -0
  16. django_cfg/apps/maintenance/apps.py +9 -96
  17. django_cfg/apps/maintenance/management/commands/maintenance.py +193 -307
  18. django_cfg/apps/maintenance/management/commands/process_scheduled_maintenance.py +241 -0
  19. django_cfg/apps/maintenance/management/commands/sync_cloudflare.py +152 -111
  20. django_cfg/apps/maintenance/managers/__init__.py +7 -12
  21. django_cfg/apps/maintenance/managers/cloudflare_site_manager.py +192 -0
  22. django_cfg/apps/maintenance/managers/maintenance_log_manager.py +151 -0
  23. django_cfg/apps/maintenance/migrations/0001_initial.py +145 -705
  24. django_cfg/apps/maintenance/migrations/0002_cloudflaresite_maintenance_url.py +21 -0
  25. django_cfg/apps/maintenance/models/__init__.py +23 -21
  26. django_cfg/apps/maintenance/models/cloudflare_api_key.py +109 -0
  27. django_cfg/apps/maintenance/models/cloudflare_site.py +125 -0
  28. django_cfg/apps/maintenance/models/maintenance_log.py +131 -0
  29. django_cfg/apps/maintenance/models/scheduled_maintenance.py +307 -0
  30. django_cfg/apps/maintenance/services/__init__.py +37 -16
  31. django_cfg/apps/maintenance/services/bulk_operations_service.py +400 -0
  32. django_cfg/apps/maintenance/services/maintenance_service.py +230 -0
  33. django_cfg/apps/maintenance/services/scheduled_maintenance_service.py +381 -0
  34. django_cfg/apps/maintenance/services/site_sync_service.py +390 -0
  35. django_cfg/apps/maintenance/utils/__init__.py +12 -0
  36. django_cfg/apps/maintenance/utils/retry_utils.py +109 -0
  37. django_cfg/config.py +3 -0
  38. django_cfg/core/config.py +4 -6
  39. django_cfg/modules/django_unfold/dashboard.py +4 -5
  40. {django_cfg-1.2.17.dist-info → django_cfg-1.2.18.dist-info}/METADATA +52 -1
  41. {django_cfg-1.2.17.dist-info → django_cfg-1.2.18.dist-info}/RECORD +45 -55
  42. django_cfg/apps/maintenance/README.md +0 -305
  43. django_cfg/apps/maintenance/admin/deployments_admin.py +0 -251
  44. django_cfg/apps/maintenance/admin/events_admin.py +0 -374
  45. django_cfg/apps/maintenance/admin/monitoring_admin.py +0 -215
  46. django_cfg/apps/maintenance/admin/sites_admin.py +0 -464
  47. django_cfg/apps/maintenance/managers/deployments.py +0 -287
  48. django_cfg/apps/maintenance/managers/events.py +0 -374
  49. django_cfg/apps/maintenance/managers/monitoring.py +0 -301
  50. django_cfg/apps/maintenance/managers/sites.py +0 -335
  51. django_cfg/apps/maintenance/models/cloudflare.py +0 -316
  52. django_cfg/apps/maintenance/models/maintenance.py +0 -334
  53. django_cfg/apps/maintenance/models/monitoring.py +0 -393
  54. django_cfg/apps/maintenance/models/sites.py +0 -419
  55. django_cfg/apps/maintenance/serializers/__init__.py +0 -60
  56. django_cfg/apps/maintenance/serializers/actions.py +0 -310
  57. django_cfg/apps/maintenance/serializers/base.py +0 -44
  58. django_cfg/apps/maintenance/serializers/deployments.py +0 -209
  59. django_cfg/apps/maintenance/serializers/events.py +0 -210
  60. django_cfg/apps/maintenance/serializers/monitoring.py +0 -278
  61. django_cfg/apps/maintenance/serializers/sites.py +0 -213
  62. django_cfg/apps/maintenance/services/README.md +0 -168
  63. django_cfg/apps/maintenance/services/cloudflare_client.py +0 -441
  64. django_cfg/apps/maintenance/services/dns_manager.py +0 -497
  65. django_cfg/apps/maintenance/services/maintenance_manager.py +0 -504
  66. django_cfg/apps/maintenance/services/site_sync.py +0 -448
  67. django_cfg/apps/maintenance/services/sync_command_service.py +0 -330
  68. django_cfg/apps/maintenance/services/worker_manager.py +0 -264
  69. django_cfg/apps/maintenance/signals.py +0 -38
  70. django_cfg/apps/maintenance/urls.py +0 -36
  71. django_cfg/apps/maintenance/views/__init__.py +0 -18
  72. django_cfg/apps/maintenance/views/base.py +0 -61
  73. django_cfg/apps/maintenance/views/deployments.py +0 -175
  74. django_cfg/apps/maintenance/views/events.py +0 -204
  75. django_cfg/apps/maintenance/views/monitoring.py +0 -213
  76. django_cfg/apps/maintenance/views/sites.py +0 -338
  77. django_cfg/models/cloudflare.py +0 -316
  78. /django_cfg/apps/accounts/{models.py → __models.py} +0 -0
  79. {django_cfg-1.2.17.dist-info → django_cfg-1.2.18.dist-info}/WHEEL +0 -0
  80. {django_cfg-1.2.17.dist-info → django_cfg-1.2.18.dist-info}/entry_points.txt +0 -0
  81. {django_cfg-1.2.17.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()