django-cfg 1.2.15__py3-none-any.whl → 1.2.16__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 (61) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/maintenance/README.md +305 -0
  3. django_cfg/apps/maintenance/__init__.py +27 -0
  4. django_cfg/apps/maintenance/admin/__init__.py +28 -0
  5. django_cfg/apps/maintenance/admin/deployments_admin.py +251 -0
  6. django_cfg/apps/maintenance/admin/events_admin.py +374 -0
  7. django_cfg/apps/maintenance/admin/monitoring_admin.py +215 -0
  8. django_cfg/apps/maintenance/admin/sites_admin.py +464 -0
  9. django_cfg/apps/maintenance/apps.py +105 -0
  10. django_cfg/apps/maintenance/management/__init__.py +0 -0
  11. django_cfg/apps/maintenance/management/commands/__init__.py +0 -0
  12. django_cfg/apps/maintenance/management/commands/maintenance.py +375 -0
  13. django_cfg/apps/maintenance/management/commands/sync_cloudflare.py +168 -0
  14. django_cfg/apps/maintenance/managers/__init__.py +20 -0
  15. django_cfg/apps/maintenance/managers/deployments.py +287 -0
  16. django_cfg/apps/maintenance/managers/events.py +374 -0
  17. django_cfg/apps/maintenance/managers/monitoring.py +301 -0
  18. django_cfg/apps/maintenance/managers/sites.py +335 -0
  19. django_cfg/apps/maintenance/migrations/0001_initial.py +939 -0
  20. django_cfg/apps/maintenance/migrations/__init__.py +0 -0
  21. django_cfg/apps/maintenance/models/__init__.py +27 -0
  22. django_cfg/apps/maintenance/models/cloudflare.py +316 -0
  23. django_cfg/apps/maintenance/models/maintenance.py +334 -0
  24. django_cfg/apps/maintenance/models/monitoring.py +393 -0
  25. django_cfg/apps/maintenance/models/sites.py +419 -0
  26. django_cfg/apps/maintenance/serializers/__init__.py +60 -0
  27. django_cfg/apps/maintenance/serializers/actions.py +310 -0
  28. django_cfg/apps/maintenance/serializers/base.py +44 -0
  29. django_cfg/apps/maintenance/serializers/deployments.py +209 -0
  30. django_cfg/apps/maintenance/serializers/events.py +210 -0
  31. django_cfg/apps/maintenance/serializers/monitoring.py +278 -0
  32. django_cfg/apps/maintenance/serializers/sites.py +213 -0
  33. django_cfg/apps/maintenance/services/README.md +168 -0
  34. django_cfg/apps/maintenance/services/__init__.py +21 -0
  35. django_cfg/apps/maintenance/services/cloudflare_client.py +441 -0
  36. django_cfg/apps/maintenance/services/dns_manager.py +497 -0
  37. django_cfg/apps/maintenance/services/maintenance_manager.py +504 -0
  38. django_cfg/apps/maintenance/services/site_sync.py +448 -0
  39. django_cfg/apps/maintenance/services/sync_command_service.py +330 -0
  40. django_cfg/apps/maintenance/services/worker_manager.py +264 -0
  41. django_cfg/apps/maintenance/signals.py +38 -0
  42. django_cfg/apps/maintenance/urls.py +36 -0
  43. django_cfg/apps/maintenance/views/__init__.py +18 -0
  44. django_cfg/apps/maintenance/views/base.py +61 -0
  45. django_cfg/apps/maintenance/views/deployments.py +175 -0
  46. django_cfg/apps/maintenance/views/events.py +204 -0
  47. django_cfg/apps/maintenance/views/monitoring.py +213 -0
  48. django_cfg/apps/maintenance/views/sites.py +338 -0
  49. django_cfg/apps/urls.py +5 -1
  50. django_cfg/core/config.py +34 -3
  51. django_cfg/core/generation.py +15 -10
  52. django_cfg/models/cloudflare.py +316 -0
  53. django_cfg/models/revolution.py +1 -1
  54. django_cfg/models/tasks.py +1 -1
  55. django_cfg/modules/base.py +12 -5
  56. django_cfg/modules/django_unfold/dashboard.py +16 -1
  57. {django_cfg-1.2.15.dist-info → django_cfg-1.2.16.dist-info}/METADATA +2 -1
  58. {django_cfg-1.2.15.dist-info → django_cfg-1.2.16.dist-info}/RECORD +61 -13
  59. {django_cfg-1.2.15.dist-info → django_cfg-1.2.16.dist-info}/WHEEL +0 -0
  60. {django_cfg-1.2.15.dist-info → django_cfg-1.2.16.dist-info}/entry_points.txt +0 -0
  61. {django_cfg-1.2.15.dist-info → django_cfg-1.2.16.dist-info}/licenses/LICENSE +0 -0
File without changes
@@ -0,0 +1,27 @@
1
+ """
2
+ Maintenance app models.
3
+
4
+ Following django-cfg patterns with proper imports and exports.
5
+ """
6
+
7
+ from .maintenance import MaintenanceEvent, MaintenanceLog
8
+ from .sites import CloudflareSite, SiteGroup
9
+ from .monitoring import MonitoringTarget, HealthCheckResult
10
+ from .cloudflare import CloudflareDeployment
11
+
12
+ __all__ = [
13
+ # Maintenance models
14
+ 'MaintenanceEvent',
15
+ 'MaintenanceLog',
16
+
17
+ # Site management models
18
+ 'CloudflareSite',
19
+ 'SiteGroup',
20
+
21
+ # Monitoring models
22
+ 'MonitoringTarget',
23
+ 'HealthCheckResult',
24
+
25
+ # Cloudflare integration models
26
+ 'CloudflareDeployment',
27
+ ]
@@ -0,0 +1,316 @@
1
+ """
2
+ Cloudflare integration models.
3
+
4
+ Models for tracking Cloudflare deployments and configurations.
5
+ Following CRITICAL_REQUIREMENTS - proper typing, no raw Dict usage.
6
+ """
7
+
8
+ from django.db import models
9
+ from django.utils import timezone
10
+ from django.core.exceptions import ValidationError
11
+ from typing import Optional, Dict, Any
12
+ from datetime import timedelta
13
+ import json
14
+
15
+
16
+ class CloudflareDeployment(models.Model):
17
+ """
18
+ Cloudflare Worker deployment tracking.
19
+
20
+ Tracks deployment of maintenance mode Workers to Cloudflare
21
+ with full audit trail and rollback capabilities.
22
+ """
23
+
24
+ class Status(models.TextChoices):
25
+ """Deployment status choices."""
26
+ PENDING = "pending", "Pending"
27
+ DEPLOYING = "deploying", "Deploying"
28
+ DEPLOYED = "deployed", "Deployed"
29
+ FAILED = "failed", "Failed"
30
+ ROLLED_BACK = "rolled_back", "Rolled Back"
31
+
32
+ class DeploymentType(models.TextChoices):
33
+ """Type of deployment."""
34
+ WORKER = "worker", "Cloudflare Worker"
35
+ PAGE_RULE = "page_rule", "Page Rule"
36
+ DNS_RECORD = "dns_record", "DNS Record"
37
+ SSL_SETTING = "ssl_setting", "SSL Setting"
38
+ CUSTOM_ERROR_PAGE = "custom_error_page", "Custom Error Page"
39
+
40
+ # === Basic Information ===
41
+ site = models.ForeignKey(
42
+ 'django_cfg_maintenance.CloudflareSite',
43
+ on_delete=models.CASCADE,
44
+ related_name='deployments',
45
+ help_text="Site this deployment belongs to"
46
+ )
47
+ deployment_type = models.CharField(
48
+ max_length=50,
49
+ choices=DeploymentType.choices,
50
+ default=DeploymentType.WORKER,
51
+ help_text="Type of Cloudflare resource being deployed"
52
+ )
53
+
54
+ # === Deployment Details ===
55
+ resource_name = models.CharField(
56
+ max_length=200,
57
+ help_text="Name of the deployed resource (Worker name, Page Rule, etc.)"
58
+ )
59
+ resource_id = models.CharField(
60
+ max_length=100,
61
+ blank=True,
62
+ help_text="Cloudflare resource ID after deployment"
63
+ )
64
+
65
+ # === Status Tracking ===
66
+ status = models.CharField(
67
+ max_length=20,
68
+ choices=Status.choices,
69
+ default=Status.PENDING,
70
+ help_text="Current deployment status"
71
+ )
72
+
73
+ # === Timing ===
74
+ created_at = models.DateTimeField(
75
+ auto_now_add=True,
76
+ help_text="When deployment was initiated"
77
+ )
78
+ deployed_at = models.DateTimeField(
79
+ null=True,
80
+ blank=True,
81
+ help_text="When deployment completed successfully"
82
+ )
83
+ failed_at = models.DateTimeField(
84
+ null=True,
85
+ blank=True,
86
+ help_text="When deployment failed"
87
+ )
88
+
89
+ # === Configuration ===
90
+ configuration = models.JSONField(
91
+ default=dict,
92
+ help_text="Deployment configuration (Worker script, Page Rule settings, etc.)"
93
+ )
94
+
95
+ # === Results ===
96
+ cloudflare_response = models.JSONField(
97
+ default=dict,
98
+ blank=True,
99
+ help_text="Full response from Cloudflare API"
100
+ )
101
+ error_message = models.TextField(
102
+ blank=True,
103
+ help_text="Error message if deployment failed"
104
+ )
105
+
106
+ # === Rollback Support ===
107
+ previous_deployment = models.ForeignKey(
108
+ 'self',
109
+ on_delete=models.SET_NULL,
110
+ null=True,
111
+ blank=True,
112
+ related_name='rollback_deployments',
113
+ help_text="Previous deployment for rollback"
114
+ )
115
+ rollback_data = models.JSONField(
116
+ default=dict,
117
+ blank=True,
118
+ help_text="Data needed for rollback (previous configuration, etc.)"
119
+ )
120
+
121
+ # === Maintenance Event Link ===
122
+ maintenance_event = models.ForeignKey(
123
+ 'django_cfg_maintenance.MaintenanceEvent',
124
+ on_delete=models.SET_NULL,
125
+ null=True,
126
+ blank=True,
127
+ related_name='cloudflare_deployments',
128
+ help_text="Related maintenance event"
129
+ )
130
+
131
+ # === Custom Manager ===
132
+ from ..managers.deployments import CloudflareDeploymentManager
133
+ objects = CloudflareDeploymentManager()
134
+
135
+ class Meta:
136
+ ordering = ['-created_at']
137
+ verbose_name = "Cloudflare Deployment"
138
+ verbose_name_plural = "Cloudflare Deployments"
139
+ indexes = [
140
+ models.Index(fields=['site', '-created_at']),
141
+ models.Index(fields=['status', '-created_at']),
142
+ models.Index(fields=['deployment_type', '-created_at']),
143
+ models.Index(fields=['resource_id']),
144
+ ]
145
+
146
+ def __str__(self) -> str:
147
+ return f"{self.get_deployment_type_display()}: {self.resource_name} ({self.get_status_display()})"
148
+
149
+ @property
150
+ def is_active(self) -> bool:
151
+ """Check if deployment is currently active."""
152
+ return self.status == self.Status.DEPLOYED
153
+
154
+ @property
155
+ def is_pending(self) -> bool:
156
+ """Check if deployment is pending."""
157
+ return self.status in [self.Status.PENDING, self.Status.DEPLOYING]
158
+
159
+ @property
160
+ def deployment_duration(self) -> Optional[timedelta]:
161
+ """Calculate deployment duration."""
162
+ if self.deployed_at:
163
+ return self.deployed_at - self.created_at
164
+ elif self.failed_at:
165
+ return self.failed_at - self.created_at
166
+ elif self.status == self.Status.DEPLOYING:
167
+ return timezone.now() - self.created_at
168
+ return None
169
+
170
+ @property
171
+ def can_rollback(self) -> bool:
172
+ """Check if deployment can be rolled back."""
173
+ return (
174
+ self.status == self.Status.DEPLOYED and
175
+ (self.previous_deployment is not None or self.rollback_data)
176
+ )
177
+
178
+ def mark_deploying(self) -> None:
179
+ """Mark deployment as in progress."""
180
+ self.status = self.Status.DEPLOYING
181
+ self.save(update_fields=['status'])
182
+
183
+ def mark_deployed(self, resource_id: str, cloudflare_response: Dict[str, Any]) -> None:
184
+ """Mark deployment as successful."""
185
+ self.status = self.Status.DEPLOYED
186
+ self.resource_id = resource_id
187
+ self.deployed_at = timezone.now()
188
+ self.cloudflare_response = cloudflare_response
189
+ self.save(update_fields=[
190
+ 'status',
191
+ 'resource_id',
192
+ 'deployed_at',
193
+ 'cloudflare_response'
194
+ ])
195
+
196
+ def mark_failed(self, error_message: str, cloudflare_response: Optional[Dict[str, Any]] = None) -> None:
197
+ """Mark deployment as failed."""
198
+ self.status = self.Status.FAILED
199
+ self.failed_at = timezone.now()
200
+ self.error_message = error_message
201
+ if cloudflare_response:
202
+ self.cloudflare_response = cloudflare_response
203
+
204
+ self.save(update_fields=[
205
+ 'status',
206
+ 'failed_at',
207
+ 'error_message',
208
+ 'cloudflare_response'
209
+ ])
210
+
211
+ def rollback(self) -> bool:
212
+ """Attempt to rollback this deployment."""
213
+ if not self.can_rollback:
214
+ return False
215
+
216
+ try:
217
+ # Create rollback deployment record
218
+ rollback_deployment = CloudflareDeployment.objects.create(
219
+ site=self.site,
220
+ deployment_type=self.deployment_type,
221
+ resource_name=f"rollback-{self.resource_name}",
222
+ configuration=self.rollback_data,
223
+ previous_deployment=self,
224
+ maintenance_event=self.maintenance_event
225
+ )
226
+
227
+ # Mark this deployment as rolled back
228
+ self.status = self.Status.ROLLED_BACK
229
+ self.save(update_fields=['status'])
230
+
231
+ return True
232
+
233
+ except Exception:
234
+ return False
235
+
236
+ def get_configuration_summary(self) -> str:
237
+ """Get human-readable configuration summary."""
238
+ if not self.configuration:
239
+ return "No configuration"
240
+
241
+ if self.deployment_type == self.DeploymentType.WORKER:
242
+ return f"Worker script ({len(str(self.configuration.get('script', '')))} chars)"
243
+ elif self.deployment_type == self.DeploymentType.PAGE_RULE:
244
+ actions = self.configuration.get('actions', [])
245
+ return f"Page Rule with {len(actions)} actions"
246
+ elif self.deployment_type == self.DeploymentType.DNS_RECORD:
247
+ record_type = self.configuration.get('type', 'Unknown')
248
+ content = self.configuration.get('content', 'Unknown')
249
+ return f"{record_type} record: {content}"
250
+ else:
251
+ return f"{len(self.configuration)} configuration items"
252
+
253
+ def get_error_summary(self) -> str:
254
+ """Get concise error summary."""
255
+ if not self.error_message:
256
+ return "No error"
257
+
258
+ # Extract first line of error message
259
+ first_line = self.error_message.split('\n')[0]
260
+ return first_line[:200] if len(first_line) > 200 else first_line
261
+
262
+ def clean(self) -> None:
263
+ """Validate model data."""
264
+ super().clean()
265
+
266
+ # Validate resource name
267
+ if not self.resource_name.strip():
268
+ raise ValidationError({'resource_name': 'Resource name cannot be empty'})
269
+
270
+ # Validate timing
271
+ if self.deployed_at and self.failed_at:
272
+ raise ValidationError("Deployment cannot be both successful and failed")
273
+
274
+ if self.deployed_at and self.deployed_at < self.created_at:
275
+ raise ValidationError({'deployed_at': 'Deployed time cannot be before created time'})
276
+
277
+ if self.failed_at and self.failed_at < self.created_at:
278
+ raise ValidationError({'failed_at': 'Failed time cannot be before created time'})
279
+
280
+ @classmethod
281
+ def create_worker_deployment(cls,
282
+ site: 'CloudflareSite',
283
+ worker_name: str,
284
+ script_content: str,
285
+ maintenance_event: Optional['MaintenanceEvent'] = None) -> 'CloudflareDeployment':
286
+ """Create Worker deployment record."""
287
+ return cls.objects.create(
288
+ site=site,
289
+ deployment_type=cls.DeploymentType.WORKER,
290
+ resource_name=worker_name,
291
+ configuration={
292
+ 'script': script_content,
293
+ 'name': worker_name
294
+ },
295
+ maintenance_event=maintenance_event
296
+ )
297
+
298
+ @classmethod
299
+ def create_page_rule_deployment(cls,
300
+ site: 'CloudflareSite',
301
+ rule_name: str,
302
+ pattern: str,
303
+ actions: Dict[str, Any],
304
+ maintenance_event: Optional['MaintenanceEvent'] = None) -> 'CloudflareDeployment':
305
+ """Create Page Rule deployment record."""
306
+ return cls.objects.create(
307
+ site=site,
308
+ deployment_type=cls.DeploymentType.PAGE_RULE,
309
+ resource_name=rule_name,
310
+ configuration={
311
+ 'targets': [{'target': 'url', 'constraint': {'operator': 'matches', 'value': pattern}}],
312
+ 'actions': actions,
313
+ 'status': 'active'
314
+ },
315
+ maintenance_event=maintenance_event
316
+ )
@@ -0,0 +1,334 @@
1
+ """
2
+ Maintenance event models.
3
+
4
+ Core models for tracking maintenance events and logs.
5
+ Following CRITICAL_REQUIREMENTS - no raw Dict usage, proper typing.
6
+ """
7
+
8
+ from django.db import models
9
+ from django.contrib.auth import get_user_model
10
+ from django.utils import timezone
11
+ from django.core.exceptions import ValidationError
12
+ from typing import Optional
13
+ from datetime import timedelta
14
+
15
+ User = get_user_model()
16
+
17
+
18
+ class MaintenanceEvent(models.Model):
19
+ """
20
+ Main maintenance event tracking model.
21
+
22
+ Tracks maintenance events with full audit trail and Cloudflare integration.
23
+ """
24
+
25
+ class Status(models.TextChoices):
26
+ """Maintenance event status choices."""
27
+ SCHEDULED = "scheduled", "Scheduled"
28
+ ACTIVE = "active", "Active"
29
+ COMPLETED = "completed", "Completed"
30
+ FAILED = "failed", "Failed"
31
+ CANCELLED = "cancelled", "Cancelled"
32
+
33
+ class Reason(models.TextChoices):
34
+ """Maintenance reason choices."""
35
+ MANUAL = "manual", "Manual Activation"
36
+ SERVER_DOWN = "server_down", "Server Unreachable"
37
+ HIGH_ERROR_RATE = "high_error_rate", "High Error Rate"
38
+ DATABASE_ISSUES = "database_issues", "Database Issues"
39
+ DEPLOYMENT = "deployment", "Deployment"
40
+ SECURITY_UPDATE = "security_update", "Security Update"
41
+ SCHEDULED = "scheduled", "Scheduled Maintenance"
42
+ EMERGENCY = "emergency", "Emergency Maintenance"
43
+
44
+ # === Basic Information ===
45
+ title = models.CharField(
46
+ max_length=200,
47
+ help_text="Human-readable maintenance event title"
48
+ )
49
+ description = models.TextField(
50
+ blank=True,
51
+ help_text="Detailed description of maintenance work"
52
+ )
53
+ reason = models.CharField(
54
+ max_length=50,
55
+ choices=Reason.choices,
56
+ default=Reason.MANUAL,
57
+ help_text="Reason for maintenance activation"
58
+ )
59
+ status = models.CharField(
60
+ max_length=20,
61
+ choices=Status.choices,
62
+ default=Status.ACTIVE,
63
+ help_text="Current maintenance status"
64
+ )
65
+
66
+ # === Timing ===
67
+ started_at = models.DateTimeField(
68
+ default=timezone.now,
69
+ help_text="When maintenance was started"
70
+ )
71
+ ended_at = models.DateTimeField(
72
+ null=True,
73
+ blank=True,
74
+ help_text="When maintenance was completed"
75
+ )
76
+ estimated_duration = models.DurationField(
77
+ null=True,
78
+ blank=True,
79
+ help_text="Estimated maintenance duration"
80
+ )
81
+
82
+ # === User Tracking ===
83
+ initiated_by = models.ForeignKey(
84
+ User,
85
+ on_delete=models.SET_NULL,
86
+ null=True,
87
+ blank=True,
88
+ related_name='initiated_maintenance_events',
89
+ help_text="User who initiated maintenance"
90
+ )
91
+ completed_by = models.ForeignKey(
92
+ User,
93
+ on_delete=models.SET_NULL,
94
+ null=True,
95
+ blank=True,
96
+ related_name='completed_maintenance_events',
97
+ help_text="User who completed maintenance"
98
+ )
99
+
100
+ # === Cloudflare Integration ===
101
+ cloudflare_worker_deployed = models.BooleanField(
102
+ default=False,
103
+ help_text="Whether Cloudflare Worker was deployed"
104
+ )
105
+ cloudflare_deployment_id = models.CharField(
106
+ max_length=100,
107
+ blank=True,
108
+ help_text="Cloudflare deployment identifier"
109
+ )
110
+ worker_deployment_success = models.BooleanField(
111
+ default=False,
112
+ help_text="Whether Worker deployment was successful"
113
+ )
114
+
115
+ # === Metrics ===
116
+ affected_requests = models.PositiveIntegerField(
117
+ default=0,
118
+ help_text="Number of requests affected during maintenance"
119
+ )
120
+ error_count_before = models.PositiveIntegerField(
121
+ default=0,
122
+ help_text="Error count before maintenance"
123
+ )
124
+ error_count_during = models.PositiveIntegerField(
125
+ default=0,
126
+ help_text="Error count during maintenance"
127
+ )
128
+
129
+ # === Sites (Many-to-Many relationship) ===
130
+ sites = models.ManyToManyField(
131
+ 'django_cfg_maintenance.CloudflareSite',
132
+ blank=True,
133
+ related_name='maintenance_events',
134
+ help_text="Sites affected by this maintenance event"
135
+ )
136
+
137
+ # === Metadata ===
138
+ created_at = models.DateTimeField(auto_now_add=True)
139
+ updated_at = models.DateTimeField(auto_now=True)
140
+
141
+ # === Custom Manager ===
142
+ from ..managers.events import MaintenanceEventManager
143
+ objects = MaintenanceEventManager()
144
+
145
+ class Meta:
146
+ ordering = ['-started_at']
147
+ verbose_name = "Maintenance Event"
148
+ verbose_name_plural = "Maintenance Events"
149
+ indexes = [
150
+ models.Index(fields=['status', '-started_at']),
151
+ models.Index(fields=['reason', '-started_at']),
152
+ models.Index(fields=['initiated_by', '-started_at']),
153
+ ]
154
+
155
+ def __str__(self) -> str:
156
+ return f"{self.title} ({self.get_status_display()})"
157
+
158
+ @property
159
+ def duration(self) -> Optional[timedelta]:
160
+ """Calculate actual maintenance duration."""
161
+ if self.ended_at:
162
+ return self.ended_at - self.started_at
163
+ elif self.status == self.Status.ACTIVE:
164
+ return timezone.now() - self.started_at
165
+ return None
166
+
167
+ @property
168
+ def is_active(self) -> bool:
169
+ """Check if maintenance is currently active."""
170
+ return self.status == self.Status.ACTIVE
171
+
172
+ @property
173
+ def is_scheduled(self) -> bool:
174
+ """Check if maintenance is scheduled for future."""
175
+ return self.status == self.Status.SCHEDULED
176
+
177
+ @property
178
+ def affected_sites_count(self) -> int:
179
+ """Count of affected sites."""
180
+ return self.sites.count()
181
+
182
+ def complete(self, user: Optional[User] = None) -> None:
183
+ """Mark maintenance as completed."""
184
+ self.status = self.Status.COMPLETED
185
+ self.ended_at = timezone.now()
186
+ self.completed_by = user
187
+ self.save(update_fields=['status', 'ended_at', 'completed_by', 'updated_at'])
188
+
189
+ def cancel(self, user: Optional[User] = None) -> None:
190
+ """Cancel maintenance event."""
191
+ self.status = self.Status.CANCELLED
192
+ self.ended_at = timezone.now()
193
+ self.completed_by = user
194
+ self.save(update_fields=['status', 'ended_at', 'completed_by', 'updated_at'])
195
+
196
+ def fail(self, error_message: str = "") -> None:
197
+ """Mark maintenance as failed."""
198
+ self.status = self.Status.FAILED
199
+ self.ended_at = timezone.now()
200
+ self.save(update_fields=['status', 'ended_at', 'updated_at'])
201
+
202
+ # Create failure log
203
+ MaintenanceLog.objects.create(
204
+ event=self,
205
+ level=MaintenanceLog.Level.ERROR,
206
+ message=f"Maintenance failed: {error_message}",
207
+ details={'error': error_message}
208
+ )
209
+
210
+ def clean(self) -> None:
211
+ """Validate model data."""
212
+ super().clean()
213
+
214
+ # Validate timing
215
+ if self.ended_at and self.ended_at < self.started_at:
216
+ raise ValidationError("End time cannot be before start time")
217
+
218
+ # Validate estimated duration
219
+ if self.estimated_duration and self.estimated_duration.total_seconds() <= 0:
220
+ raise ValidationError("Estimated duration must be positive")
221
+
222
+
223
+ class MaintenanceLog(models.Model):
224
+ """
225
+ Detailed logging for maintenance events.
226
+
227
+ Provides audit trail and debugging information for maintenance operations.
228
+ """
229
+
230
+ class Level(models.TextChoices):
231
+ """Log level choices."""
232
+ DEBUG = "debug", "Debug"
233
+ INFO = "info", "Info"
234
+ WARNING = "warning", "Warning"
235
+ ERROR = "error", "Error"
236
+ CRITICAL = "critical", "Critical"
237
+
238
+ # === Relationships ===
239
+ event = models.ForeignKey(
240
+ MaintenanceEvent,
241
+ on_delete=models.CASCADE,
242
+ related_name='logs',
243
+ help_text="Related maintenance event"
244
+ )
245
+
246
+ # === Log Data ===
247
+ level = models.CharField(
248
+ max_length=20,
249
+ choices=Level.choices,
250
+ default=Level.INFO,
251
+ help_text="Log level"
252
+ )
253
+ message = models.TextField(
254
+ help_text="Log message"
255
+ )
256
+ details = models.JSONField(
257
+ default=dict,
258
+ blank=True,
259
+ help_text="Additional log details (JSON)"
260
+ )
261
+
262
+ # === Context ===
263
+ component = models.CharField(
264
+ max_length=100,
265
+ blank=True,
266
+ help_text="Component that generated the log (e.g., 'cloudflare', 'monitoring')"
267
+ )
268
+ operation = models.CharField(
269
+ max_length=100,
270
+ blank=True,
271
+ help_text="Operation being performed (e.g., 'deploy_worker', 'health_check')"
272
+ )
273
+
274
+ # === User Context ===
275
+ user = models.ForeignKey(
276
+ User,
277
+ on_delete=models.SET_NULL,
278
+ null=True,
279
+ blank=True,
280
+ help_text="User associated with this log entry"
281
+ )
282
+
283
+ # === Metadata ===
284
+ timestamp = models.DateTimeField(
285
+ default=timezone.now,
286
+ help_text="When the log entry was created"
287
+ )
288
+
289
+ # === Custom Manager ===
290
+ from ..managers.events import MaintenanceLogManager
291
+ objects = MaintenanceLogManager()
292
+
293
+ class Meta:
294
+ ordering = ['-timestamp']
295
+ verbose_name = "Maintenance Log"
296
+ verbose_name_plural = "Maintenance Logs"
297
+ indexes = [
298
+ models.Index(fields=['event', '-timestamp']),
299
+ models.Index(fields=['level', '-timestamp']),
300
+ models.Index(fields=['component', '-timestamp']),
301
+ ]
302
+
303
+ def __str__(self) -> str:
304
+ return f"{self.get_level_display()}: {self.message[:100]}"
305
+
306
+ @classmethod
307
+ def log_info(cls, event: MaintenanceEvent, message: str, **kwargs) -> 'MaintenanceLog':
308
+ """Create info log entry."""
309
+ return cls.objects.create(
310
+ event=event,
311
+ level=cls.Level.INFO,
312
+ message=message,
313
+ **kwargs
314
+ )
315
+
316
+ @classmethod
317
+ def log_error(cls, event: MaintenanceEvent, message: str, **kwargs) -> 'MaintenanceLog':
318
+ """Create error log entry."""
319
+ return cls.objects.create(
320
+ event=event,
321
+ level=cls.Level.ERROR,
322
+ message=message,
323
+ **kwargs
324
+ )
325
+
326
+ @classmethod
327
+ def log_warning(cls, event: MaintenanceEvent, message: str, **kwargs) -> 'MaintenanceLog':
328
+ """Create warning log entry."""
329
+ return cls.objects.create(
330
+ event=event,
331
+ level=cls.Level.WARNING,
332
+ message=message,
333
+ **kwargs
334
+ )