This commit is contained in:
Iliyan Angelov
2025-09-19 11:58:53 +03:00
parent 306b20e24a
commit 6b247e5b9f
11423 changed files with 1500615 additions and 778 deletions

View File

@@ -0,0 +1,733 @@
# SLA & On-Call Management API Documentation
## Overview
The SLA & On-Call Management module provides comprehensive Service Level Agreement (SLA) tracking, escalation policies, and on-call rotation management for enterprise incident management systems.
## Features
### Dynamic SLAs
- **Incident Type-Based SLAs**: Different SLA targets based on incident category, severity, and priority
- **Business Hours Support**: SLA calculations that respect business hours and timezones
- **Multiple SLA Types**: Response time, resolution time, acknowledgment time, and first response time
- **Automatic SLA Instance Creation**: SLAs are automatically created when incidents are reported
### Escalation Policies
- **Multi-Level Escalation**: Configurable escalation steps with different actions and timing
- **Condition-Based Triggering**: Escalations triggered by SLA breaches, thresholds, or custom conditions
- **Multi-Channel Notifications**: Email, SMS, Slack, Teams, and webhook notifications
- **Integration with On-Call**: Automatic escalation to current on-call personnel
### On-Call Rotation Management
- **Flexible Scheduling**: Weekly, daily, monthly, and custom rotation schedules
- **External System Integration**: Built-in support for PagerDuty and OpsGenie
- **Handoff Management**: Structured handoff processes with notes and tracking
- **Performance Metrics**: Track incident handling and response times
### Business Hours Management
- **Timezone Support**: Multiple timezone configurations
- **Holiday Calendar**: Holiday and special day handling
- **Day Overrides**: Custom hours for specific dates
- **Weekend Configuration**: Separate weekend business hours
## API Endpoints
### Business Hours Management
#### GET /api/sla-oncall/api/v1/business-hours/
List all business hours configurations.
**Query Parameters:**
- `is_active`: Filter by active status
- `is_default`: Filter by default status
- `timezone`: Filter by timezone
- `search`: Search by name or description
**Response:**
```json
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": "uuid",
"name": "Standard Business Hours",
"description": "Standard 9-5 business hours",
"timezone": "UTC",
"weekday_start": "09:00:00",
"weekday_end": "17:00:00",
"weekend_start": "10:00:00",
"weekend_end": "16:00:00",
"day_overrides": {},
"holiday_calendar": [],
"is_active": true,
"is_default": true,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
#### POST /api/sla-oncall/api/v1/business-hours/
Create a new business hours configuration.
**Request Body:**
```json
{
"name": "Custom Business Hours",
"description": "Custom business hours for special team",
"timezone": "America/New_York",
"weekday_start": "08:00:00",
"weekday_end": "18:00:00",
"weekend_start": "10:00:00",
"weekend_end": "16:00:00",
"holiday_calendar": ["2024-12-25", "2024-01-01"],
"is_active": true
}
```
#### POST /api/sla-oncall/api/v1/business-hours/{id}/test_business_hours/
Test if a given time is within business hours.
**Request Body:**
```json
{
"test_time": "2024-01-08T14:30:00Z"
}
```
**Response:**
```json
{
"is_business_hours": true,
"test_time": "2024-01-08T14:30:00Z"
}
```
### SLA Definitions
#### GET /api/sla-oncall/api/v1/sla-definitions/
List all SLA definitions.
**Query Parameters:**
- `sla_type`: Filter by SLA type (RESPONSE_TIME, RESOLUTION_TIME, etc.)
- `is_active`: Filter by active status
- `business_hours_only`: Filter by business hours requirement
**Response:**
```json
{
"count": 3,
"results": [
{
"id": "uuid",
"name": "Critical Incident Response",
"description": "SLA for critical incidents",
"sla_type": "RESPONSE_TIME",
"incident_categories": ["SYSTEM", "NETWORK"],
"incident_severities": ["CRITICAL", "EMERGENCY"],
"incident_priorities": ["P1"],
"target_duration_minutes": 15,
"business_hours_only": false,
"business_hours": null,
"business_hours_name": null,
"escalation_enabled": true,
"escalation_threshold_percent": 75.0,
"is_active": true,
"is_default": false,
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
#### POST /api/sla-oncall/api/v1/sla-definitions/
Create a new SLA definition.
**Request Body:**
```json
{
"name": "High Priority Response",
"description": "SLA for high priority incidents",
"sla_type": "RESPONSE_TIME",
"incident_severities": ["HIGH"],
"incident_priorities": ["P2"],
"target_duration_minutes": 30,
"business_hours_only": false,
"escalation_enabled": true,
"escalation_threshold_percent": 80.0,
"is_active": true
}
```
#### POST /api/sla-oncall/api/v1/sla-definitions/{id}/test_applicability/
Test if SLA definition applies to a given incident.
**Request Body:**
```json
{
"category": "SYSTEM",
"severity": "HIGH",
"priority": "P2"
}
```
**Response:**
```json
{
"applies": true,
"incident_data": {
"category": "SYSTEM",
"severity": "HIGH",
"priority": "P2"
}
}
```
### On-Call Rotations
#### GET /api/sla-oncall/api/v1/oncall-rotations/
List all on-call rotations.
**Query Parameters:**
- `rotation_type`: Filter by rotation type (WEEKLY, DAILY, etc.)
- `status`: Filter by status (ACTIVE, PAUSED, INACTIVE)
- `external_system`: Filter by external system integration
**Response:**
```json
{
"count": 1,
"results": [
{
"id": "uuid",
"name": "Primary On-Call Rotation",
"description": "Primary rotation for incident response",
"rotation_type": "WEEKLY",
"status": "ACTIVE",
"team_name": "Incident Response Team",
"team_description": "Primary team responsible for incidents",
"schedule_config": {
"rotation_length_days": 7,
"handoff_time": "09:00"
},
"timezone": "UTC",
"external_system": "INTERNAL",
"external_system_id": null,
"integration_config": {},
"current_oncall": {
"user_id": "uuid",
"username": "john.doe",
"start_time": "2024-01-08T09:00:00Z",
"end_time": "2024-01-15T09:00:00Z"
},
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
#### GET /api/sla-oncall/api/v1/oncall-rotations/{id}/current_oncall/
Get the current on-call person for a rotation.
**Response:**
```json
{
"id": "uuid",
"rotation": "uuid",
"rotation_name": "Primary On-Call Rotation",
"user": "uuid",
"user_name": "john.doe",
"user_email": "john.doe@company.com",
"start_time": "2024-01-08T09:00:00Z",
"end_time": "2024-01-15T09:00:00Z",
"status": "ACTIVE",
"incidents_handled": 5,
"response_time_avg": "00:15:30"
}
```
#### GET /api/sla-oncall/api/v1/oncall-rotations/{id}/upcoming_assignments/
Get upcoming on-call assignments.
**Query Parameters:**
- `days`: Number of days ahead to look (default: 30)
### SLA Instances
#### GET /api/sla-oncall/api/v1/sla-instances/
List all SLA instances.
**Query Parameters:**
- `status`: Filter by status (ACTIVE, MET, BREACHED, CANCELLED)
- `escalation_triggered`: Filter by escalation status
- `sla_definition`: Filter by SLA definition
**Response:**
```json
{
"count": 10,
"results": [
{
"id": "uuid",
"sla_definition": "uuid",
"sla_definition_name": "Critical Incident Response",
"incident": "uuid",
"incident_title": "Database Connection Failure",
"status": "ACTIVE",
"target_time": "2024-01-08T15:15:00Z",
"started_at": "2024-01-08T15:00:00Z",
"met_at": null,
"breached_at": null,
"escalation_policy": "uuid",
"escalation_triggered": false,
"escalation_triggered_at": null,
"escalation_level": 0,
"response_time": null,
"resolution_time": null,
"is_breached": false,
"time_remaining": "00:12:30",
"breach_time": "00:00:00",
"created_at": "2024-01-08T15:00:00Z"
}
]
}
```
#### GET /api/sla-oncall/api/v1/sla-instances/breached/
Get all breached SLA instances.
#### GET /api/sla-oncall/api/v1/sla-instances/at_risk/
Get SLA instances at risk of breaching (within 15 minutes).
#### POST /api/sla-oncall/api/v1/sla-instances/{id}/mark_met/
Mark an SLA instance as met.
**Response:**
```json
{
"message": "SLA marked as met"
}
```
#### POST /api/sla-oncall/api/v1/sla-instances/{id}/mark_breached/
Mark an SLA instance as breached.
### On-Call Assignments
#### GET /api/sla-oncall/api/v1/oncall-assignments/
List all on-call assignments.
**Query Parameters:**
- `rotation`: Filter by rotation
- `user`: Filter by user
- `status`: Filter by status (SCHEDULED, ACTIVE, COMPLETED, CANCELLED)
#### POST /api/sla-oncall/api/v1/oncall-assignments/
Create a new on-call assignment.
**Request Body:**
```json
{
"rotation": "uuid",
"user": "uuid",
"start_time": "2024-01-15T09:00:00Z",
"end_time": "2024-01-22T09:00:00Z",
"handoff_notes": "All systems stable, no pending incidents"
}
```
#### POST /api/sla-oncall/api/v1/oncall-assignments/{id}/handoff/
Perform on-call handoff.
**Request Body:**
```json
{
"handoff_notes": "Handing off to next person. 3 active incidents."
}
```
#### POST /api/sla-oncall/api/v1/oncall-assignments/{id}/activate/
Activate a scheduled assignment.
#### POST /api/sla-oncall/api/v1/oncall-assignments/{id}/complete/
Complete an active assignment.
### Escalation Policies
#### GET /api/sla-oncall/api/v1/escalation-policies/
List all escalation policies.
**Query Parameters:**
- `escalation_type`: Filter by escalation type
- `trigger_condition`: Filter by trigger condition
- `is_active`: Filter by active status
#### POST /api/sla-oncall/api/v1/escalation-policies/
Create a new escalation policy.
**Request Body:**
```json
{
"name": "Critical Escalation",
"description": "Escalation for critical incidents",
"escalation_type": "TIME_BASED",
"trigger_condition": "SLA_THRESHOLD",
"incident_severities": ["CRITICAL", "EMERGENCY"],
"trigger_delay_minutes": 0,
"escalation_steps": [
{
"level": 1,
"delay_minutes": 5,
"actions": ["notify_oncall", "notify_manager"],
"channels": ["email", "sms"]
},
{
"level": 2,
"delay_minutes": 15,
"actions": ["notify_director", "page_oncall"],
"channels": ["email", "sms", "phone"]
}
],
"notification_channels": ["email", "sms", "phone"],
"is_active": true
}
```
### Escalation Instances
#### GET /api/sla-oncall/api/v1/escalation-instances/
List all escalation instances.
**Query Parameters:**
- `status`: Filter by status (PENDING, TRIGGERED, ACKNOWLEDGED, RESOLVED, CANCELLED)
- `escalation_level`: Filter by escalation level
- `escalation_policy`: Filter by escalation policy
#### POST /api/sla-oncall/api/v1/escalation-instances/{id}/acknowledge/
Acknowledge an escalation.
#### POST /api/sla-oncall/api/v1/escalation-instances/{id}/resolve/
Resolve an escalation.
### Notification Templates
#### GET /api/sla-oncall/api/v1/notification-templates/
List all notification templates.
**Query Parameters:**
- `template_type`: Filter by template type (ESCALATION, ONCALL_HANDOFF, etc.)
- `channel_type`: Filter by channel type (EMAIL, SMS, SLACK, etc.)
- `is_active`: Filter by active status
#### POST /api/sla-oncall/api/v1/notification-templates/
Create a new notification template.
**Request Body:**
```json
{
"name": "Email Escalation Alert",
"template_type": "ESCALATION",
"channel_type": "EMAIL",
"subject_template": "URGENT: Incident #{incident_id} Escalated",
"body_template": "Incident #{incident_id} has been escalated to Level {escalation_level}. Please respond immediately.",
"variables": ["incident_id", "incident_title", "escalation_level"],
"is_active": true,
"is_default": true
}
```
## Setup and Configuration
### Initial Setup
Run the setup command to create default configurations:
```bash
python manage.py setup_sla_oncall
```
This command creates:
- Default business hours configurations
- Standard SLA definitions for different incident types
- Default escalation policies
- Notification templates
- Sample on-call rotation (if users exist)
### Configuration Examples
#### Business Hours for Different Teams
```python
# 24/7 Operations
business_hours = BusinessHours.objects.create(
name='24/7 Operations',
description='Always business hours',
timezone='UTC',
weekday_start=time(0, 0),
weekday_end=time(23, 59),
weekend_start=time(0, 0),
weekend_end=time(23, 59),
)
# EMEA Business Hours
business_hours = BusinessHours.objects.create(
name='EMEA Business Hours',
description='EMEA timezone business hours',
timezone='Europe/London',
weekday_start=time(9, 0),
weekday_end=time(17, 0),
weekend_start=time(10, 0),
weekend_end=time(16, 0),
holiday_calendar=['2024-12-25', '2024-01-01', '2024-04-19'],
)
```
#### SLA Definitions
```python
# Critical incidents - 15 minute response
critical_sla = SLADefinition.objects.create(
name='Critical Incident Response',
description='SLA for critical and emergency incidents',
sla_type='RESPONSE_TIME',
incident_severities=['CRITICAL', 'EMERGENCY'],
incident_priorities=['P1'],
target_duration_minutes=15,
business_hours_only=False,
escalation_enabled=True,
escalation_threshold_percent=75.0,
)
# Medium incidents - 2 hour response during business hours
medium_sla = SLADefinition.objects.create(
name='Medium Priority Response',
description='SLA for medium priority incidents',
sla_type='RESPONSE_TIME',
incident_severities=['MEDIUM'],
incident_priorities=['P3'],
target_duration_minutes=120,
business_hours_only=True,
business_hours=business_hours,
escalation_enabled=True,
escalation_threshold_percent=85.0,
)
```
#### Escalation Policies
```python
# Critical escalation policy
escalation_policy = EscalationPolicy.objects.create(
name='Critical Incident Escalation',
description='Escalation for critical incidents',
escalation_type='TIME_BASED',
trigger_condition='SLA_THRESHOLD',
incident_severities=['CRITICAL', 'EMERGENCY'],
trigger_delay_minutes=0,
escalation_steps=[
{
'level': 1,
'delay_minutes': 5,
'actions': ['notify_oncall', 'notify_manager'],
'channels': ['email', 'sms']
},
{
'level': 2,
'delay_minutes': 15,
'actions': ['notify_director', 'page_oncall'],
'channels': ['email', 'sms', 'phone']
},
{
'level': 3,
'delay_minutes': 30,
'actions': ['notify_executive', 'escalate_to_vendor'],
'channels': ['email', 'phone', 'webhook']
}
],
notification_channels=['email', 'sms', 'phone'],
)
```
#### On-Call Rotations
```python
# Weekly rotation
rotation = OnCallRotation.objects.create(
name='Primary On-Call Rotation',
description='Primary rotation for incident response',
rotation_type='WEEKLY',
team_name='Incident Response Team',
schedule_config={
'rotation_length_days': 7,
'handoff_time': '09:00',
'timezone': 'UTC'
},
timezone='UTC',
)
# Create assignments
assignment = OnCallAssignment.objects.create(
rotation=rotation,
user=user,
start_time=timezone.now(),
end_time=timezone.now() + timedelta(days=7),
status='ACTIVE'
)
```
## Integration with Other Modules
### Incident Intelligence Integration
The SLA module automatically creates SLA instances when incidents are created:
```python
# When an incident is created, applicable SLA definitions are found
# and SLA instances are automatically created
incident = Incident.objects.create(
title='Database Connection Failure',
description='Unable to connect to primary database',
severity='CRITICAL',
category='DATABASE',
reporter=user,
)
# This automatically triggers SLA instance creation via signals
```
### Automation Orchestration Integration
SLA breaches can trigger automation workflows:
```python
# In automation_orchestration models, you can reference SLA instances
class RunbookExecution(models.Model):
# ... existing fields ...
sla_instance = models.ForeignKey(
'sla_oncall.SLAInstance',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='runbook_executions'
)
```
### Security Integration
On-call assignments respect security clearances:
```python
# Users with appropriate clearance levels can be assigned to sensitive incidents
if user.clearance_level.level >= incident.get_required_clearance_level():
# User can be assigned to this incident
assignment = OnCallAssignment.objects.create(...)
```
## Monitoring and Alerting
### SLA Breach Monitoring
Monitor SLA instances for breaches:
```python
# Get all breached SLAs
breached_slas = SLAInstance.objects.filter(status='BREACHED')
# Get SLAs at risk (within 15 minutes of breach)
warning_time = timezone.now() + timedelta(minutes=15)
at_risk_slas = SLAInstance.objects.filter(
status='ACTIVE',
target_time__lte=warning_time
)
```
### Escalation Monitoring
Monitor active escalations:
```python
# Get all active escalations
active_escalations = EscalationInstance.objects.filter(
status__in=['PENDING', 'TRIGGERED']
)
# Get escalations by level
level_2_escalations = EscalationInstance.objects.filter(
escalation_level=2,
status='TRIGGERED'
)
```
### Performance Metrics
Track on-call performance:
```python
# Get current on-call assignments
current_assignments = OnCallAssignment.objects.filter(
status='ACTIVE',
start_time__lte=timezone.now(),
end_time__gte=timezone.now()
)
# Calculate average response time
avg_response_time = current_assignments.aggregate(
avg_response=Avg('response_time_avg')
)['avg_response']
```
## Best Practices
### SLA Definition Best Practices
1. **Start Simple**: Begin with basic SLAs and add complexity as needed
2. **Business Hours Consideration**: Use business hours for non-critical incidents
3. **Escalation Thresholds**: Set escalation thresholds at 75-85% of SLA time
4. **Regular Review**: Review and adjust SLAs based on performance data
### On-Call Management Best Practices
1. **Clear Handoffs**: Use structured handoff processes with notes
2. **Rotation Length**: Keep rotations between 1-2 weeks for optimal coverage
3. **Backup Coverage**: Always have backup on-call personnel
4. **Training**: Ensure on-call personnel are properly trained
### Escalation Best Practices
1. **Progressive Escalation**: Use multiple levels with increasing urgency
2. **Clear Actions**: Define specific actions for each escalation level
3. **Multiple Channels**: Use multiple notification channels for critical escalations
4. **Documentation**: Document all escalation actions and outcomes
## Troubleshooting
### Common Issues
1. **SLA Not Created**: Check if SLA definition criteria match incident attributes
2. **Escalation Not Triggered**: Verify escalation policy is active and criteria match
3. **On-Call Not Found**: Ensure active assignments exist for the rotation
4. **Business Hours Issues**: Verify timezone configuration and business hours setup
### Debugging Commands
```bash
# Check SLA instances for a specific incident
python manage.py shell
>>> incident = Incident.objects.get(id='incident-id')
>>> sla_instances = incident.sla_instances.all()
>>> for sla in sla_instances:
... print(f"SLA: {sla.sla_definition.name}, Status: {sla.status}")
# Check current on-call for a rotation
>>> rotation = OnCallRotation.objects.get(id='rotation-id')
>>> current = rotation.get_current_oncall()
>>> print(f"Current on-call: {current.user.username if current else 'None'}")
# Check business hours
>>> business_hours = BusinessHours.objects.get(id='business-hours-id')
>>> now = timezone.now()
>>> print(f"Is business hours: {business_hours.is_business_hours(now)}")
```

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

293
ETB-API/sla_oncall/admin.py Normal file
View File

@@ -0,0 +1,293 @@
"""
Admin configuration for SLA & On-Call Management
"""
from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from datetime import timedelta
from .models import (
BusinessHours,
SLADefinition,
EscalationPolicy,
OnCallRotation,
OnCallAssignment,
SLAInstance,
EscalationInstance,
NotificationTemplate,
)
@admin.register(BusinessHours)
class BusinessHoursAdmin(admin.ModelAdmin):
list_display = ['name', 'timezone', 'weekday_start', 'weekday_end', 'is_active', 'is_default']
list_filter = ['is_active', 'is_default', 'timezone']
search_fields = ['name', 'description']
ordering = ['name']
fieldsets = (
('Basic Information', {
'fields': ('name', 'description', 'timezone')
}),
('Business Hours', {
'fields': ('weekday_start', 'weekday_end', 'weekend_start', 'weekend_end')
}),
('Overrides', {
'fields': ('day_overrides', 'holiday_calendar'),
'classes': ('collapse',)
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(SLADefinition)
class SLADefinitionAdmin(admin.ModelAdmin):
list_display = [
'name', 'sla_type', 'target_duration_minutes',
'business_hours_only', 'is_active', 'is_default'
]
list_filter = ['sla_type', 'business_hours_only', 'is_active', 'is_default']
search_fields = ['name', 'description']
ordering = ['name']
fieldsets = (
('Basic Information', {
'fields': ('name', 'description', 'sla_type')
}),
('Targeting Criteria', {
'fields': ('incident_categories', 'incident_severities', 'incident_priorities')
}),
('SLA Configuration', {
'fields': ('target_duration_minutes', 'business_hours_only', 'business_hours')
}),
('Escalation', {
'fields': ('escalation_enabled', 'escalation_threshold_percent')
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(EscalationPolicy)
class EscalationPolicyAdmin(admin.ModelAdmin):
list_display = [
'name', 'escalation_type', 'trigger_condition',
'trigger_delay_minutes', 'is_active'
]
list_filter = ['escalation_type', 'trigger_condition', 'is_active']
search_fields = ['name', 'description']
ordering = ['name']
fieldsets = (
('Basic Information', {
'fields': ('name', 'description', 'escalation_type', 'trigger_condition')
}),
('Targeting', {
'fields': ('incident_severities', 'incident_categories')
}),
('Configuration', {
'fields': ('trigger_delay_minutes', 'escalation_steps')
}),
('Notifications', {
'fields': ('notification_channels', 'notification_templates'),
'classes': ('collapse',)
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(OnCallRotation)
class OnCallRotationAdmin(admin.ModelAdmin):
list_display = [
'name', 'rotation_type', 'team_name', 'external_system', 'status'
]
list_filter = ['rotation_type', 'external_system', 'status']
search_fields = ['name', 'team_name', 'description']
ordering = ['name']
fieldsets = (
('Basic Information', {
'fields': ('name', 'description', 'rotation_type', 'status')
}),
('Team Configuration', {
'fields': ('team_name', 'team_description')
}),
('Schedule', {
'fields': ('schedule_config', 'timezone')
}),
('External Integration', {
'fields': ('external_system', 'external_system_id', 'integration_config'),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(OnCallAssignment)
class OnCallAssignmentAdmin(admin.ModelAdmin):
list_display = [
'user', 'rotation', 'start_time', 'end_time', 'status', 'incidents_handled'
]
list_filter = ['status', 'rotation', 'start_time']
search_fields = ['user__username', 'user__email', 'rotation__name']
ordering = ['-start_time']
fieldsets = (
('Assignment Details', {
'fields': ('rotation', 'user', 'start_time', 'end_time', 'status')
}),
('Handoff Information', {
'fields': ('handoff_notes', 'handed_off_from', 'handoff_time'),
'classes': ('collapse',)
}),
('Performance', {
'fields': ('incidents_handled', 'response_time_avg')
}),
('Metadata', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(SLAInstance)
class SLAInstanceAdmin(admin.ModelAdmin):
list_display = [
'incident', 'sla_definition', 'status', 'target_time',
'escalation_triggered', 'time_remaining_display'
]
list_filter = ['status', 'escalation_triggered', 'sla_definition__sla_type']
search_fields = ['incident__title', 'sla_definition__name']
ordering = ['-created_at']
fieldsets = (
('SLA Details', {
'fields': ('sla_definition', 'incident', 'status', 'target_time')
}),
('Timing', {
'fields': ('started_at', 'met_at', 'breached_at')
}),
('Escalation', {
'fields': ('escalation_policy', 'escalation_triggered',
'escalation_triggered_at', 'escalation_level')
}),
('Performance', {
'fields': ('response_time', 'resolution_time')
}),
('Metadata', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at', 'started_at']
def time_remaining_display(self, obj):
"""Display time remaining with color coding"""
if obj.status == 'ACTIVE':
remaining = obj.time_remaining
if remaining > timedelta(hours=1):
color = 'green'
elif remaining > timedelta(minutes=15):
color = 'orange'
else:
color = 'red'
return format_html(
'<span style="color: {};">{}</span>',
color,
str(remaining).split('.')[0] if remaining else 'Expired'
)
return '-'
time_remaining_display.short_description = 'Time Remaining'
@admin.register(EscalationInstance)
class EscalationInstanceAdmin(admin.ModelAdmin):
list_display = [
'incident', 'escalation_policy', 'status', 'escalation_level',
'triggered_at', 'current_step'
]
list_filter = ['status', 'escalation_level', 'escalation_policy__escalation_type']
search_fields = ['incident__title', 'escalation_policy__name']
ordering = ['-created_at']
fieldsets = (
('Escalation Details', {
'fields': ('escalation_policy', 'incident', 'sla_instance', 'status')
}),
('Progress', {
'fields': ('escalation_level', 'current_step')
}),
('Timing', {
'fields': ('triggered_at', 'acknowledged_at', 'resolved_at')
}),
('Actions', {
'fields': ('notifications_sent', 'actions_taken'),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
@admin.register(NotificationTemplate)
class NotificationTemplateAdmin(admin.ModelAdmin):
list_display = [
'name', 'template_type', 'channel_type', 'is_active', 'is_default'
]
list_filter = ['template_type', 'channel_type', 'is_active', 'is_default']
search_fields = ['name', 'subject_template']
ordering = ['template_type', 'channel_type', 'name']
fieldsets = (
('Basic Information', {
'fields': ('name', 'template_type', 'channel_type')
}),
('Template Content', {
'fields': ('subject_template', 'body_template', 'variables')
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']

View File

@@ -0,0 +1,14 @@
from django.apps import AppConfig
class SlaOncallConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'sla_oncall'
verbose_name = 'SLA & On-Call Management'
def ready(self):
"""Import signal handlers when the app is ready"""
try:
import sla_oncall.signals
except ImportError:
pass

View File

@@ -0,0 +1 @@
# Management commands for SLA & On-Call Management

View File

@@ -0,0 +1 @@
# Management commands

View File

@@ -0,0 +1,395 @@
"""
Management command to set up SLA & On-Call Management with default configurations
"""
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from datetime import time, timedelta
from django.utils import timezone
from sla_oncall.models import (
BusinessHours,
SLADefinition,
EscalationPolicy,
OnCallRotation,
OnCallAssignment,
NotificationTemplate,
)
User = get_user_model()
class Command(BaseCommand):
help = 'Set up SLA & On-Call Management with default configurations'
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='Force recreation of existing configurations',
)
def handle(self, *args, **options):
self.stdout.write('Setting up SLA & On-Call Management...')
force = options['force']
# Create default business hours
self.create_default_business_hours(force)
# Create default SLA definitions
self.create_default_sla_definitions(force)
# Create default escalation policies
self.create_default_escalation_policies(force)
# Create default notification templates
self.create_default_notification_templates(force)
# Create sample on-call rotation (if users exist)
self.create_sample_oncall_rotation(force)
self.stdout.write(
self.style.SUCCESS('Successfully set up SLA & On-Call Management!')
)
def create_default_business_hours(self, force):
"""Create default business hours configurations"""
self.stdout.write('Creating default business hours...')
default_hours, created = BusinessHours.objects.get_or_create(
name='Standard Business Hours',
defaults={
'description': 'Standard 9-5 business hours, Monday to Friday',
'timezone': 'UTC',
'weekday_start': time(9, 0),
'weekday_end': time(17, 0),
'weekend_start': time(10, 0),
'weekend_end': time(16, 0),
'is_active': True,
'is_default': True,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {default_hours.name}')
# 24/7 business hours
twenty_four_seven, created = BusinessHours.objects.get_or_create(
name='24/7 Operations',
defaults={
'description': '24/7 operations - always business hours',
'timezone': 'UTC',
'weekday_start': time(0, 0),
'weekday_end': time(23, 59),
'weekend_start': time(0, 0),
'weekend_end': time(23, 59),
'is_active': True,
'is_default': False,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {twenty_four_seven.name}')
def create_default_sla_definitions(self, force):
"""Create default SLA definitions"""
self.stdout.write('Creating default SLA definitions...')
# Critical incidents SLA
critical_sla, created = SLADefinition.objects.get_or_create(
name='Critical Incident Response',
defaults={
'description': 'SLA for critical and emergency incidents',
'sla_type': 'RESPONSE_TIME',
'incident_severities': ['CRITICAL', 'EMERGENCY'],
'incident_priorities': ['P1'],
'target_duration_minutes': 15,
'business_hours_only': False,
'escalation_enabled': True,
'escalation_threshold_percent': 75.0,
'is_active': True,
'is_default': False,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {critical_sla.name}')
# High priority incidents SLA
high_sla, created = SLADefinition.objects.get_or_create(
name='High Priority Response',
defaults={
'description': 'SLA for high priority incidents',
'sla_type': 'RESPONSE_TIME',
'incident_severities': ['HIGH'],
'incident_priorities': ['P2'],
'target_duration_minutes': 30,
'business_hours_only': False,
'escalation_enabled': True,
'escalation_threshold_percent': 80.0,
'is_active': True,
'is_default': False,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {high_sla.name}')
# Medium priority incidents SLA
medium_sla, created = SLADefinition.objects.get_or_create(
name='Medium Priority Response',
defaults={
'description': 'SLA for medium priority incidents during business hours',
'sla_type': 'RESPONSE_TIME',
'incident_severities': ['MEDIUM'],
'incident_priorities': ['P3'],
'target_duration_minutes': 120,
'business_hours_only': True,
'escalation_enabled': True,
'escalation_threshold_percent': 85.0,
'is_active': True,
'is_default': False,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {medium_sla.name}')
# Resolution time SLA
resolution_sla, created = SLADefinition.objects.get_or_create(
name='Critical Incident Resolution',
defaults={
'description': 'SLA for resolving critical incidents',
'sla_type': 'RESOLUTION_TIME',
'incident_severities': ['CRITICAL', 'EMERGENCY'],
'incident_priorities': ['P1'],
'target_duration_minutes': 240, # 4 hours
'business_hours_only': False,
'escalation_enabled': True,
'escalation_threshold_percent': 90.0,
'is_active': True,
'is_default': False,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {resolution_sla.name}')
def create_default_escalation_policies(self, force):
"""Create default escalation policies"""
self.stdout.write('Creating default escalation policies...')
# Critical escalation policy
critical_escalation, created = EscalationPolicy.objects.get_or_create(
name='Critical Incident Escalation',
defaults={
'description': 'Escalation policy for critical and emergency incidents',
'escalation_type': 'TIME_BASED',
'trigger_condition': 'SLA_THRESHOLD',
'incident_severities': ['CRITICAL', 'EMERGENCY'],
'trigger_delay_minutes': 0,
'escalation_steps': [
{
'level': 1,
'delay_minutes': 5,
'actions': ['notify_oncall', 'notify_manager'],
'channels': ['email', 'sms']
},
{
'level': 2,
'delay_minutes': 15,
'actions': ['notify_director', 'page_oncall'],
'channels': ['email', 'sms', 'phone']
},
{
'level': 3,
'delay_minutes': 30,
'actions': ['notify_executive', 'escalate_to_vendor'],
'channels': ['email', 'phone', 'webhook']
}
],
'notification_channels': ['email', 'sms', 'phone'],
'is_active': True,
'is_default': False,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {critical_escalation.name}')
# Standard escalation policy
standard_escalation, created = EscalationPolicy.objects.get_or_create(
name='Standard Escalation',
defaults={
'description': 'Standard escalation policy for most incidents',
'escalation_type': 'TIME_BASED',
'trigger_condition': 'SLA_THRESHOLD',
'incident_severities': ['LOW', 'MEDIUM', 'HIGH'],
'trigger_delay_minutes': 5,
'escalation_steps': [
{
'level': 1,
'delay_minutes': 15,
'actions': ['notify_oncall'],
'channels': ['email']
},
{
'level': 2,
'delay_minutes': 30,
'actions': ['notify_manager'],
'channels': ['email', 'sms']
}
],
'notification_channels': ['email', 'sms'],
'is_active': True,
'is_default': True,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {standard_escalation.name}')
def create_default_notification_templates(self, force):
"""Create default notification templates"""
self.stdout.write('Creating default notification templates...')
# Email escalation template
email_escalation, created = NotificationTemplate.objects.get_or_create(
name='Email Escalation Alert',
template_type='ESCALATION',
channel_type='EMAIL',
defaults={
'subject_template': 'URGENT: Incident #{incident_id} Escalated - {incident_title}',
'body_template': '''
Incident #{incident_id} has been escalated to Level {escalation_level}.
Details:
- Title: {incident_title}
- Severity: {incident_severity}
- Status: {incident_status}
- Created: {incident_created_at}
- SLA Target: {sla_target_time}
- Current On-Call: {current_oncall}
Please respond immediately.
View incident: {incident_url}
''',
'variables': [
'incident_id', 'incident_title', 'incident_severity',
'incident_status', 'incident_created_at', 'sla_target_time',
'current_oncall', 'incident_url', 'escalation_level'
],
'is_active': True,
'is_default': True,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {email_escalation.name}')
# SMS escalation template
sms_escalation, created = NotificationTemplate.objects.get_or_create(
name='SMS Escalation Alert',
template_type='ESCALATION',
channel_type='SMS',
defaults={
'subject_template': '',
'body_template': 'URGENT: Incident #{incident_id} escalated to L{escalation_level}. {incident_title}. Respond now.',
'variables': ['incident_id', 'incident_title', 'escalation_level'],
'is_active': True,
'is_default': True,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {sms_escalation.name}')
# SLA breach template
sla_breach, created = NotificationTemplate.objects.get_or_create(
name='SLA Breach Alert',
template_type='SLA_BREACH',
channel_type='EMAIL',
defaults={
'subject_template': 'SLA BREACH: Incident #{incident_id} - {incident_title}',
'body_template': '''
SLA BREACH ALERT
The SLA for incident #{incident_id} has been breached.
Details:
- Incident: {incident_title}
- SLA Type: {sla_type}
- Target Time: {sla_target_time}
- Breach Time: {breach_time}
- Breach Duration: {breach_duration}
Immediate action required.
''',
'variables': [
'incident_id', 'incident_title', 'sla_type',
'sla_target_time', 'breach_time', 'breach_duration'
],
'is_active': True,
'is_default': True,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {sla_breach.name}')
def create_sample_oncall_rotation(self, force):
"""Create a sample on-call rotation if users exist"""
self.stdout.write('Creating sample on-call rotation...')
# Check if we have any users
if not User.objects.exists():
self.stdout.write(' ⚠ No users found. Skipping on-call rotation creation.')
return
# Get first user as admin
admin_user = User.objects.first()
# Create sample rotation
sample_rotation, created = OnCallRotation.objects.get_or_create(
name='Primary On-Call Rotation',
defaults={
'description': 'Primary on-call rotation for incident response',
'rotation_type': 'WEEKLY',
'status': 'ACTIVE',
'team_name': 'Incident Response Team',
'team_description': 'Primary team responsible for incident response',
'schedule_config': {
'rotation_length_days': 7,
'handoff_time': '09:00',
'timezone': 'UTC'
},
'timezone': 'UTC',
'external_system': 'INTERNAL',
'created_by': admin_user,
}
)
if created or force:
self.stdout.write(f' ✓ Created/Updated: {sample_rotation.name}')
# Create a sample assignment for the next week
if User.objects.count() >= 2:
users = list(User.objects.all()[:2])
start_time = timezone.now()
end_time = start_time + timedelta(days=7)
assignment, created = OnCallAssignment.objects.get_or_create(
rotation=sample_rotation,
user=users[0],
start_time=start_time,
end_time=end_time,
defaults={
'status': 'ACTIVE',
}
)
if created:
self.stdout.write(f' ✓ Created assignment for {users[0].username}')
else:
self.stdout.write(f' ⚠ Rotation already exists: {sample_rotation.name}')

View File

@@ -0,0 +1,283 @@
# Generated by Django 5.2.6 on 2025-09-18 15:50
import datetime
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('incident_intelligence', '0003_incident_auto_remediation_attempted_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BusinessHours',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, unique=True)),
('description', models.TextField()),
('timezone', models.CharField(default='UTC', max_length=50)),
('weekday_start', models.TimeField(default=datetime.time(9, 0))),
('weekday_end', models.TimeField(default=datetime.time(17, 0))),
('weekend_start', models.TimeField(default=datetime.time(10, 0))),
('weekend_end', models.TimeField(default=datetime.time(16, 0))),
('day_overrides', models.JSONField(default=dict, help_text='Override hours for specific dates (YYYY-MM-DD format)')),
('holiday_calendar', models.JSONField(default=list, help_text="List of holidays (YYYY-MM-DD format) when business hours don't apply")),
('is_active', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False, help_text='Default business hours for the system')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='EscalationPolicy',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, unique=True)),
('description', models.TextField()),
('escalation_type', models.CharField(choices=[('TIME_BASED', 'Time-based Escalation'), ('SEVERITY_BASED', 'Severity-based Escalation'), ('RESOURCE_BASED', 'Resource-based Escalation'), ('CUSTOM', 'Custom Escalation')], max_length=20)),
('trigger_condition', models.CharField(choices=[('SLA_BREACH', 'SLA Breach'), ('SLA_THRESHOLD', 'SLA Threshold Reached'), ('NO_RESPONSE', 'No Response'), ('NO_ACKNOWLEDGMENT', 'No Acknowledgment'), ('CUSTOM', 'Custom Condition')], max_length=20)),
('incident_severities', models.JSONField(default=list, help_text='List of incident severities this policy applies to')),
('incident_categories', models.JSONField(default=list, help_text='List of incident categories this policy applies to')),
('trigger_delay_minutes', models.PositiveIntegerField(default=0, help_text='Delay before escalation triggers (in minutes)')),
('escalation_steps', models.JSONField(default=list, help_text='List of escalation steps with timing and actions')),
('notification_channels', models.JSONField(default=list, help_text='Channels to notify during escalation (email, sms, slack, etc.)')),
('notification_templates', models.JSONField(default=dict, help_text='Templates for different notification channels')),
('is_active', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='NotificationTemplate',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('template_type', models.CharField(choices=[('ESCALATION', 'Escalation Notification'), ('ONCALL_HANDOFF', 'On-Call Handoff'), ('SLA_BREACH', 'SLA Breach Alert'), ('SLA_WARNING', 'SLA Warning'), ('CUSTOM', 'Custom Notification')], max_length=20)),
('channel_type', models.CharField(choices=[('EMAIL', 'Email'), ('SMS', 'SMS'), ('SLACK', 'Slack'), ('TEAMS', 'Microsoft Teams'), ('WEBHOOK', 'Webhook'), ('CUSTOM', 'Custom Channel')], max_length=20)),
('subject_template', models.CharField(help_text='Subject template with variables', max_length=500)),
('body_template', models.TextField(help_text='Body template with variables')),
('variables', models.JSONField(default=list, help_text='Available variables for this template')),
('is_active', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['template_type', 'channel_type', 'name'],
},
),
migrations.CreateModel(
name='OnCallRotation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, unique=True)),
('description', models.TextField()),
('rotation_type', models.CharField(choices=[('WEEKLY', 'Weekly Rotation'), ('DAILY', 'Daily Rotation'), ('MONTHLY', 'Monthly Rotation'), ('CUSTOM', 'Custom Schedule')], max_length=20)),
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('PAUSED', 'Paused'), ('INACTIVE', 'Inactive')], default='ACTIVE', max_length=20)),
('team_name', models.CharField(max_length=100)),
('team_description', models.TextField(blank=True, null=True)),
('schedule_config', models.JSONField(default=dict, help_text='Configuration for the rotation schedule')),
('timezone', models.CharField(default='UTC', max_length=50)),
('external_system', models.CharField(choices=[('PAGERDUTY', 'PagerDuty'), ('OPSGENIE', 'OpsGenie'), ('INTERNAL', 'Internal System'), ('CUSTOM', 'Custom Integration')], default='INTERNAL', max_length=50)),
('external_system_id', models.CharField(blank=True, help_text='ID in external system (PagerDuty schedule ID, etc.)', max_length=255, null=True)),
('integration_config', models.JSONField(default=dict, help_text='Configuration for external system integration')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='OnCallAssignment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('start_time', models.DateTimeField()),
('end_time', models.DateTimeField()),
('status', models.CharField(choices=[('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('CANCELLED', 'Cancelled')], default='SCHEDULED', max_length=20)),
('handoff_notes', models.TextField(blank=True, null=True)),
('handoff_time', models.DateTimeField(blank=True, null=True)),
('incidents_handled', models.PositiveIntegerField(default=0)),
('response_time_avg', models.DurationField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('handed_off_from', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='handed_off_assignments', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('rotation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='sla_oncall.oncallrotation')),
],
options={
'ordering': ['start_time'],
},
),
migrations.CreateModel(
name='SLADefinition',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('description', models.TextField()),
('sla_type', models.CharField(choices=[('RESPONSE_TIME', 'Response Time'), ('RESOLUTION_TIME', 'Resolution Time'), ('ACKNOWLEDGMENT_TIME', 'Acknowledgment Time'), ('FIRST_RESPONSE', 'First Response Time')], max_length=20)),
('incident_categories', models.JSONField(default=list, help_text='List of incident categories this SLA applies to')),
('incident_severities', models.JSONField(default=list, help_text='List of incident severities this SLA applies to')),
('incident_priorities', models.JSONField(default=list, help_text='List of incident priorities this SLA applies to')),
('target_duration_minutes', models.PositiveIntegerField(help_text='SLA target in minutes')),
('business_hours_only', models.BooleanField(default=False, help_text='Whether SLA only applies during business hours')),
('escalation_enabled', models.BooleanField(default=True)),
('escalation_threshold_percent', models.FloatField(default=80.0, help_text='Escalate when X% of SLA time has passed', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(100.0)])),
('is_active', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business_hours', models.ForeignKey(blank=True, help_text='Business hours configuration for this SLA', null=True, on_delete=django.db.models.deletion.SET_NULL, to='sla_oncall.businesshours')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='SLAInstance',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('MET', 'SLA Met'), ('BREACHED', 'SLA Breached'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20)),
('target_time', models.DateTimeField(help_text='When the SLA should be met')),
('started_at', models.DateTimeField(auto_now_add=True)),
('met_at', models.DateTimeField(blank=True, null=True)),
('breached_at', models.DateTimeField(blank=True, null=True)),
('escalation_triggered', models.BooleanField(default=False)),
('escalation_triggered_at', models.DateTimeField(blank=True, null=True)),
('escalation_level', models.PositiveIntegerField(default=0)),
('response_time', models.DurationField(blank=True, null=True)),
('resolution_time', models.DurationField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('escalation_policy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='sla_oncall.escalationpolicy')),
('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sla_instances', to='incident_intelligence.incident')),
('sla_definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sla_oncall.sladefinition')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EscalationInstance',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('TRIGGERED', 'Triggered'), ('ACKNOWLEDGED', 'Acknowledged'), ('RESOLVED', 'Resolved'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
('escalation_level', models.PositiveIntegerField(default=1)),
('current_step', models.PositiveIntegerField(default=0)),
('triggered_at', models.DateTimeField(blank=True, null=True)),
('acknowledged_at', models.DateTimeField(blank=True, null=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('notifications_sent', models.JSONField(default=list, help_text='List of notifications sent during escalation')),
('actions_taken', models.JSONField(default=list, help_text='List of actions taken during escalation')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_instances', to='incident_intelligence.incident')),
('escalation_policy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sla_oncall.escalationpolicy')),
('sla_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='escalation_instances', to='sla_oncall.slainstance')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='businesshours',
index=models.Index(fields=['is_active', 'is_default'], name='sla_oncall__is_acti_5700c2_idx'),
),
migrations.AddIndex(
model_name='escalationpolicy',
index=models.Index(fields=['escalation_type', 'is_active'], name='sla_oncall__escalat_f6341c_idx'),
),
migrations.AddIndex(
model_name='escalationpolicy',
index=models.Index(fields=['trigger_condition'], name='sla_oncall__trigger_75de52_idx'),
),
migrations.AddIndex(
model_name='notificationtemplate',
index=models.Index(fields=['template_type', 'channel_type'], name='sla_oncall__templat_a3daf6_idx'),
),
migrations.AddIndex(
model_name='notificationtemplate',
index=models.Index(fields=['is_active'], name='sla_oncall__is_acti_cb24d6_idx'),
),
migrations.AlterUniqueTogether(
name='notificationtemplate',
unique_together={('template_type', 'channel_type', 'name')},
),
migrations.AddIndex(
model_name='oncallrotation',
index=models.Index(fields=['status', 'rotation_type'], name='sla_oncall__status_0522d6_idx'),
),
migrations.AddIndex(
model_name='oncallrotation',
index=models.Index(fields=['external_system'], name='sla_oncall__externa_ad3fd1_idx'),
),
migrations.AddIndex(
model_name='oncallassignment',
index=models.Index(fields=['rotation', 'start_time'], name='sla_oncall__rotatio_2db363_idx'),
),
migrations.AddIndex(
model_name='oncallassignment',
index=models.Index(fields=['user', 'start_time'], name='sla_oncall__user_id_0776dc_idx'),
),
migrations.AddIndex(
model_name='oncallassignment',
index=models.Index(fields=['status', 'start_time'], name='sla_oncall__status_3bec52_idx'),
),
migrations.AddIndex(
model_name='sladefinition',
index=models.Index(fields=['sla_type', 'is_active'], name='sla_oncall__sla_typ_f69c8d_idx'),
),
migrations.AddIndex(
model_name='sladefinition',
index=models.Index(fields=['incident_severities'], name='sla_oncall__inciden_ba4baa_idx'),
),
migrations.AddIndex(
model_name='sladefinition',
index=models.Index(fields=['incident_categories'], name='sla_oncall__inciden_da3c38_idx'),
),
migrations.AddIndex(
model_name='slainstance',
index=models.Index(fields=['incident', 'status'], name='sla_oncall__inciden_723905_idx'),
),
migrations.AddIndex(
model_name='slainstance',
index=models.Index(fields=['sla_definition', 'status'], name='sla_oncall__sla_def_57544b_idx'),
),
migrations.AddIndex(
model_name='slainstance',
index=models.Index(fields=['target_time', 'status'], name='sla_oncall__target__b6b252_idx'),
),
migrations.AddIndex(
model_name='escalationinstance',
index=models.Index(fields=['incident', 'status'], name='sla_oncall__inciden_58c9bd_idx'),
),
migrations.AddIndex(
model_name='escalationinstance',
index=models.Index(fields=['escalation_policy', 'status'], name='sla_oncall__escalat_f748a6_idx'),
),
migrations.AddIndex(
model_name='escalationinstance',
index=models.Index(fields=['triggered_at'], name='sla_oncall__trigger_a8b2b2_idx'),
),
]

View File

@@ -0,0 +1,653 @@
"""
SLA & On-Call Enhancement models for Enterprise Incident Management API
Implements dynamic SLAs, escalation policies, on-call rotations, and business hours management
"""
import uuid
import json
from datetime import datetime, timedelta, time
from typing import Dict, Any, Optional, List
from zoneinfo import ZoneInfo
from django.db import models
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator, MaxValueValidator
from django.utils import timezone
from django.core.exceptions import ValidationError
User = get_user_model()
class BusinessHours(models.Model):
"""Business hours configuration for different teams and services"""
WEEKDAYS = [
('MONDAY', 'Monday'),
('TUESDAY', 'Tuesday'),
('WEDNESDAY', 'Wednesday'),
('THURSDAY', 'Thursday'),
('FRIDAY', 'Friday'),
('SATURDAY', 'Saturday'),
('SUNDAY', 'Sunday'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200, unique=True)
description = models.TextField()
timezone = models.CharField(max_length=50, default='UTC')
# Business hours configuration
weekday_start = models.TimeField(default=time(9, 0)) # 9:00 AM
weekday_end = models.TimeField(default=time(17, 0)) # 5:00 PM
weekend_start = models.TimeField(default=time(10, 0)) # 10:00 AM
weekend_end = models.TimeField(default=time(16, 0)) # 4:00 PM
# Specific day overrides
day_overrides = models.JSONField(
default=dict,
help_text="Override hours for specific dates (YYYY-MM-DD format)"
)
# Holiday configuration
holiday_calendar = models.JSONField(
default=list,
help_text="List of holidays (YYYY-MM-DD format) when business hours don't apply"
)
# Status
is_active = models.BooleanField(default=True)
is_default = models.BooleanField(default=False, help_text="Default business hours for the system")
# Metadata
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['is_active', 'is_default']),
]
def __str__(self):
return f"{self.name} ({self.timezone})"
def is_business_hours(self, check_time: datetime = None) -> bool:
"""Check if given time (or now) is within business hours"""
if check_time is None:
check_time = timezone.now()
# Convert to business hours timezone
if check_time.tzinfo is None:
check_time = timezone.make_aware(check_time)
local_time = check_time.astimezone(ZoneInfo(self.timezone))
current_date = local_time.date()
current_time = local_time.time()
# Check for holiday
if current_date.strftime('%Y-%m-%d') in self.holiday_calendar:
return False
# Check for day override
date_key = current_date.strftime('%Y-%m-%d')
if date_key in self.day_overrides:
override = self.day_overrides[date_key]
if override.get('is_holiday', False):
return False
start_time = time.fromisoformat(override.get('start_time', '09:00'))
end_time = time.fromisoformat(override.get('end_time', '17:00'))
return start_time <= current_time <= end_time
# Regular business hours
is_weekend = current_date.weekday() >= 5 # Saturday = 5, Sunday = 6
if is_weekend:
return self.weekend_start <= current_time <= self.weekend_end
else:
return self.weekday_start <= current_time <= self.weekday_end
class SLADefinition(models.Model):
"""Dynamic SLA definitions based on incident type, severity, and business context"""
SLA_TYPES = [
('RESPONSE_TIME', 'Response Time'),
('RESOLUTION_TIME', 'Resolution Time'),
('ACKNOWLEDGMENT_TIME', 'Acknowledgment Time'),
('FIRST_RESPONSE', 'First Response Time'),
]
SEVERITY_CHOICES = [
('ALL', 'All Severities'),
('LOW', 'Low'),
('MEDIUM', 'Medium'),
('HIGH', 'High'),
('CRITICAL', 'Critical'),
('EMERGENCY', 'Emergency'),
]
PRIORITY_CHOICES = [
('ALL', 'All Priorities'),
('P1', 'P1 - Critical'),
('P2', 'P2 - High'),
('P3', 'P3 - Medium'),
('P4', 'P4 - Low'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
description = models.TextField()
sla_type = models.CharField(max_length=20, choices=SLA_TYPES)
# Targeting criteria
incident_categories = models.JSONField(
default=list,
help_text="List of incident categories this SLA applies to"
)
incident_severities = models.JSONField(
default=list,
help_text="List of incident severities this SLA applies to"
)
incident_priorities = models.JSONField(
default=list,
help_text="List of incident priorities this SLA applies to"
)
# SLA targets
target_duration_minutes = models.PositiveIntegerField(
help_text="SLA target in minutes"
)
business_hours_only = models.BooleanField(
default=False,
help_text="Whether SLA only applies during business hours"
)
business_hours = models.ForeignKey(
BusinessHours,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Business hours configuration for this SLA"
)
# Escalation configuration
escalation_enabled = models.BooleanField(default=True)
escalation_threshold_percent = models.FloatField(
default=80.0,
validators=[MinValueValidator(0.0), MaxValueValidator(100.0)],
help_text="Escalate when X% of SLA time has passed"
)
# Status and metadata
is_active = models.BooleanField(default=True)
is_default = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['sla_type', 'is_active']),
models.Index(fields=['incident_severities']),
models.Index(fields=['incident_categories']),
]
def __str__(self):
return f"{self.name} ({self.sla_type})"
def applies_to_incident(self, incident) -> bool:
"""Check if this SLA applies to the given incident"""
if not self.is_active:
return False
# Check categories
if self.incident_categories and incident.category not in self.incident_categories:
return False
# Check severities
if self.incident_severities and incident.severity not in self.incident_severities:
return False
# Check priorities
if self.incident_priorities and incident.priority not in self.incident_priorities:
return False
return True
class EscalationPolicy(models.Model):
"""Escalation policies for incidents and SLAs"""
ESCALATION_TYPES = [
('TIME_BASED', 'Time-based Escalation'),
('SEVERITY_BASED', 'Severity-based Escalation'),
('RESOURCE_BASED', 'Resource-based Escalation'),
('CUSTOM', 'Custom Escalation'),
]
TRIGGER_CONDITIONS = [
('SLA_BREACH', 'SLA Breach'),
('SLA_THRESHOLD', 'SLA Threshold Reached'),
('NO_RESPONSE', 'No Response'),
('NO_ACKNOWLEDGMENT', 'No Acknowledgment'),
('CUSTOM', 'Custom Condition'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200, unique=True)
description = models.TextField()
escalation_type = models.CharField(max_length=20, choices=ESCALATION_TYPES)
trigger_condition = models.CharField(max_length=20, choices=TRIGGER_CONDITIONS)
# Targeting criteria
incident_severities = models.JSONField(
default=list,
help_text="List of incident severities this policy applies to"
)
incident_categories = models.JSONField(
default=list,
help_text="List of incident categories this policy applies to"
)
# Escalation configuration
trigger_delay_minutes = models.PositiveIntegerField(
default=0,
help_text="Delay before escalation triggers (in minutes)"
)
escalation_steps = models.JSONField(
default=list,
help_text="List of escalation steps with timing and actions"
)
# Notification configuration
notification_channels = models.JSONField(
default=list,
help_text="Channels to notify during escalation (email, sms, slack, etc.)"
)
notification_templates = models.JSONField(
default=dict,
help_text="Templates for different notification channels"
)
# Status and metadata
is_active = models.BooleanField(default=True)
is_default = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['escalation_type', 'is_active']),
models.Index(fields=['trigger_condition']),
]
def __str__(self):
return f"{self.name} ({self.escalation_type})"
def applies_to_incident(self, incident) -> bool:
"""Check if this escalation policy applies to the given incident"""
if not self.is_active:
return False
# Check severities
if self.incident_severities and incident.severity not in self.incident_severities:
return False
# Check categories
if self.incident_categories and incident.category not in self.incident_categories:
return False
return True
class OnCallRotation(models.Model):
"""On-call rotation management"""
ROTATION_TYPES = [
('WEEKLY', 'Weekly Rotation'),
('DAILY', 'Daily Rotation'),
('MONTHLY', 'Monthly Rotation'),
('CUSTOM', 'Custom Schedule'),
]
STATUS_CHOICES = [
('ACTIVE', 'Active'),
('PAUSED', 'Paused'),
('INACTIVE', 'Inactive'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200, unique=True)
description = models.TextField()
rotation_type = models.CharField(max_length=20, choices=ROTATION_TYPES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ACTIVE')
# Team configuration
team_name = models.CharField(max_length=100)
team_description = models.TextField(blank=True, null=True)
# Schedule configuration
schedule_config = models.JSONField(
default=dict,
help_text="Configuration for the rotation schedule"
)
timezone = models.CharField(max_length=50, default='UTC')
# Integration configuration
external_system = models.CharField(
max_length=50,
choices=[
('PAGERDUTY', 'PagerDuty'),
('OPSGENIE', 'OpsGenie'),
('INTERNAL', 'Internal System'),
('CUSTOM', 'Custom Integration'),
],
default='INTERNAL'
)
external_system_id = models.CharField(
max_length=255,
blank=True,
null=True,
help_text="ID in external system (PagerDuty schedule ID, etc.)"
)
integration_config = models.JSONField(
default=dict,
help_text="Configuration for external system integration"
)
# Status and metadata
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['status', 'rotation_type']),
models.Index(fields=['external_system']),
]
def __str__(self):
return f"{self.name} ({self.rotation_type})"
def get_current_oncall(self, check_time: datetime = None):
"""Get the current on-call person for the given time"""
if check_time is None:
check_time = timezone.now()
return self.assignments.filter(
start_time__lte=check_time,
end_time__gte=check_time,
status='ACTIVE'
).first()
class OnCallAssignment(models.Model):
"""Individual on-call assignments within rotations"""
STATUS_CHOICES = [
('SCHEDULED', 'Scheduled'),
('ACTIVE', 'Active'),
('COMPLETED', 'Completed'),
('CANCELLED', 'Cancelled'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
rotation = models.ForeignKey(
OnCallRotation,
on_delete=models.CASCADE,
related_name='assignments'
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
# Schedule
start_time = models.DateTimeField()
end_time = models.DateTimeField()
# Status
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='SCHEDULED')
# Handoff information
handoff_notes = models.TextField(blank=True, null=True)
handed_off_from = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='handed_off_assignments'
)
handoff_time = models.DateTimeField(null=True, blank=True)
# Performance tracking
incidents_handled = models.PositiveIntegerField(default=0)
response_time_avg = models.DurationField(null=True, blank=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['start_time']
indexes = [
models.Index(fields=['rotation', 'start_time']),
models.Index(fields=['user', 'start_time']),
models.Index(fields=['status', 'start_time']),
]
def __str__(self):
return f"{self.user.username} on-call {self.start_time} - {self.end_time}"
def is_current(self, check_time: datetime = None) -> bool:
"""Check if this assignment is currently active"""
if check_time is None:
check_time = timezone.now()
return (self.status == 'ACTIVE' and
self.start_time <= check_time <= self.end_time)
class SLAInstance(models.Model):
"""Instance of SLA tracking for a specific incident"""
STATUS_CHOICES = [
('ACTIVE', 'Active'),
('MET', 'SLA Met'),
('BREACHED', 'SLA Breached'),
('CANCELLED', 'Cancelled'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
sla_definition = models.ForeignKey(SLADefinition, on_delete=models.CASCADE)
incident = models.ForeignKey(
'incident_intelligence.Incident',
on_delete=models.CASCADE,
related_name='sla_instances'
)
# SLA tracking
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ACTIVE')
target_time = models.DateTimeField(help_text="When the SLA should be met")
# Timing
started_at = models.DateTimeField(auto_now_add=True)
met_at = models.DateTimeField(null=True, blank=True)
breached_at = models.DateTimeField(null=True, blank=True)
# Escalation tracking
escalation_policy = models.ForeignKey(
EscalationPolicy,
on_delete=models.SET_NULL,
null=True,
blank=True
)
escalation_triggered = models.BooleanField(default=False)
escalation_triggered_at = models.DateTimeField(null=True, blank=True)
escalation_level = models.PositiveIntegerField(default=0)
# Performance metrics
response_time = models.DurationField(null=True, blank=True)
resolution_time = models.DurationField(null=True, blank=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['incident', 'status']),
models.Index(fields=['sla_definition', 'status']),
models.Index(fields=['target_time', 'status']),
]
def __str__(self):
return f"SLA {self.sla_definition.name} for {self.incident.title}"
@property
def is_breached(self) -> bool:
"""Check if SLA is breached"""
if self.status == 'BREACHED':
return True
if self.status == 'ACTIVE':
return timezone.now() > self.target_time
return False
@property
def time_remaining(self) -> timedelta:
"""Get time remaining until SLA breach"""
if self.status != 'ACTIVE':
return timedelta(0)
remaining = self.target_time - timezone.now()
return remaining if remaining > timedelta(0) else timedelta(0)
@property
def breach_time(self) -> timedelta:
"""Get time since SLA breach"""
if not self.is_breached:
return timedelta(0)
return timezone.now() - self.target_time
class EscalationInstance(models.Model):
"""Instance of escalation for a specific incident"""
STATUS_CHOICES = [
('PENDING', 'Pending'),
('TRIGGERED', 'Triggered'),
('ACKNOWLEDGED', 'Acknowledged'),
('RESOLVED', 'Resolved'),
('CANCELLED', 'Cancelled'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
escalation_policy = models.ForeignKey(EscalationPolicy, on_delete=models.CASCADE)
incident = models.ForeignKey(
'incident_intelligence.Incident',
on_delete=models.CASCADE,
related_name='escalation_instances'
)
sla_instance = models.ForeignKey(
SLAInstance,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='escalation_instances'
)
# Escalation details
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
escalation_level = models.PositiveIntegerField(default=1)
current_step = models.PositiveIntegerField(default=0)
# Timing
triggered_at = models.DateTimeField(null=True, blank=True)
acknowledged_at = models.DateTimeField(null=True, blank=True)
resolved_at = models.DateTimeField(null=True, blank=True)
# Actions taken
notifications_sent = models.JSONField(
default=list,
help_text="List of notifications sent during escalation"
)
actions_taken = models.JSONField(
default=list,
help_text="List of actions taken during escalation"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['incident', 'status']),
models.Index(fields=['escalation_policy', 'status']),
models.Index(fields=['triggered_at']),
]
def __str__(self):
return f"Escalation {self.escalation_policy.name} for {self.incident.title}"
class NotificationTemplate(models.Model):
"""Templates for escalation and on-call notifications"""
TEMPLATE_TYPES = [
('ESCALATION', 'Escalation Notification'),
('ONCALL_HANDOFF', 'On-Call Handoff'),
('SLA_BREACH', 'SLA Breach Alert'),
('SLA_WARNING', 'SLA Warning'),
('CUSTOM', 'Custom Notification'),
]
CHANNEL_TYPES = [
('EMAIL', 'Email'),
('SMS', 'SMS'),
('SLACK', 'Slack'),
('TEAMS', 'Microsoft Teams'),
('WEBHOOK', 'Webhook'),
('CUSTOM', 'Custom Channel'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
template_type = models.CharField(max_length=20, choices=TEMPLATE_TYPES)
channel_type = models.CharField(max_length=20, choices=CHANNEL_TYPES)
# Template content
subject_template = models.CharField(
max_length=500,
help_text="Subject template with variables"
)
body_template = models.TextField(
help_text="Body template with variables"
)
variables = models.JSONField(
default=list,
help_text="Available variables for this template"
)
# Status and metadata
is_active = models.BooleanField(default=True)
is_default = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['template_type', 'channel_type', 'name']
unique_together = ['template_type', 'channel_type', 'name']
indexes = [
models.Index(fields=['template_type', 'channel_type']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"{self.name} ({self.template_type} - {self.channel_type})"

View File

@@ -0,0 +1 @@
# Serializers for SLA & On-Call Management

View File

@@ -0,0 +1,281 @@
"""
Serializers for SLA & On-Call Management
"""
from datetime import timedelta
from rest_framework import serializers
from django.contrib.auth import get_user_model
from sla_oncall.models import (
BusinessHours,
SLADefinition,
EscalationPolicy,
OnCallRotation,
OnCallAssignment,
SLAInstance,
EscalationInstance,
NotificationTemplate,
)
User = get_user_model()
class BusinessHoursSerializer(serializers.ModelSerializer):
"""Serializer for BusinessHours model"""
class Meta:
model = BusinessHours
fields = [
'id', 'name', 'description', 'timezone',
'weekday_start', 'weekday_end', 'weekend_start', 'weekend_end',
'day_overrides', 'holiday_calendar',
'is_active', 'is_default',
'created_by', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def validate(self, data):
"""Validate business hours configuration"""
if data.get('weekday_start') >= data.get('weekday_end'):
raise serializers.ValidationError(
"Weekday start time must be before weekday end time"
)
if data.get('weekend_start') >= data.get('weekend_end'):
raise serializers.ValidationError(
"Weekend start time must be before weekend end time"
)
return data
class SLADefinitionSerializer(serializers.ModelSerializer):
"""Serializer for SLADefinition model"""
business_hours_name = serializers.CharField(
source='business_hours.name',
read_only=True
)
class Meta:
model = SLADefinition
fields = [
'id', 'name', 'description', 'sla_type',
'incident_categories', 'incident_severities', 'incident_priorities',
'target_duration_minutes', 'business_hours_only', 'business_hours',
'business_hours_name',
'escalation_enabled', 'escalation_threshold_percent',
'is_active', 'is_default',
'created_by', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def validate_escalation_threshold_percent(self, value):
"""Validate escalation threshold percentage"""
if not (0 <= value <= 100):
raise serializers.ValidationError(
"Escalation threshold must be between 0 and 100 percent"
)
return value
class EscalationPolicySerializer(serializers.ModelSerializer):
"""Serializer for EscalationPolicy model"""
class Meta:
model = EscalationPolicy
fields = [
'id', 'name', 'description', 'escalation_type', 'trigger_condition',
'incident_severities', 'incident_categories',
'trigger_delay_minutes', 'escalation_steps',
'notification_channels', 'notification_templates',
'is_active', 'is_default',
'created_by', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
class OnCallRotationSerializer(serializers.ModelSerializer):
"""Serializer for OnCallRotation model"""
current_oncall = serializers.SerializerMethodField()
class Meta:
model = OnCallRotation
fields = [
'id', 'name', 'description', 'rotation_type', 'status',
'team_name', 'team_description',
'schedule_config', 'timezone',
'external_system', 'external_system_id', 'integration_config',
'current_oncall',
'created_by', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'current_oncall']
def get_current_oncall(self, obj):
"""Get current on-call person"""
current_assignment = obj.get_current_oncall()
if current_assignment:
return {
'user_id': current_assignment.user.id,
'username': current_assignment.user.username,
'start_time': current_assignment.start_time,
'end_time': current_assignment.end_time
}
return None
class OnCallAssignmentSerializer(serializers.ModelSerializer):
"""Serializer for OnCallAssignment model"""
user_name = serializers.CharField(source='user.username', read_only=True)
user_email = serializers.CharField(source='user.email', read_only=True)
rotation_name = serializers.CharField(source='rotation.name', read_only=True)
class Meta:
model = OnCallAssignment
fields = [
'id', 'rotation', 'rotation_name', 'user', 'user_name', 'user_email',
'start_time', 'end_time', 'status',
'handoff_notes', 'handed_off_from', 'handoff_time',
'incidents_handled', 'response_time_avg',
'created_at', 'updated_at'
]
read_only_fields = [
'id', 'created_at', 'updated_at', 'user_name', 'user_email', 'rotation_name'
]
def validate(self, data):
"""Validate assignment times"""
if data.get('start_time') >= data.get('end_time'):
raise serializers.ValidationError(
"Start time must be before end time"
)
return data
class SLAInstanceSerializer(serializers.ModelSerializer):
"""Serializer for SLAInstance model"""
incident_title = serializers.CharField(source='incident.title', read_only=True)
sla_definition_name = serializers.CharField(source='sla_definition.name', read_only=True)
is_breached = serializers.BooleanField(read_only=True)
time_remaining = serializers.DurationField(read_only=True)
breach_time = serializers.DurationField(read_only=True)
class Meta:
model = SLAInstance
fields = [
'id', 'sla_definition', 'sla_definition_name', 'incident', 'incident_title',
'status', 'target_time',
'started_at', 'met_at', 'breached_at',
'escalation_policy', 'escalation_triggered', 'escalation_triggered_at',
'escalation_level', 'response_time', 'resolution_time',
'is_breached', 'time_remaining', 'breach_time',
'created_at', 'updated_at'
]
read_only_fields = [
'id', 'created_at', 'updated_at', 'started_at',
'is_breached', 'time_remaining', 'breach_time',
'incident_title', 'sla_definition_name'
]
class EscalationInstanceSerializer(serializers.ModelSerializer):
"""Serializer for EscalationInstance model"""
incident_title = serializers.CharField(source='incident.title', read_only=True)
escalation_policy_name = serializers.CharField(source='escalation_policy.name', read_only=True)
class Meta:
model = EscalationInstance
fields = [
'id', 'escalation_policy', 'escalation_policy_name',
'incident', 'incident_title', 'sla_instance',
'status', 'escalation_level', 'current_step',
'triggered_at', 'acknowledged_at', 'resolved_at',
'notifications_sent', 'actions_taken',
'created_at', 'updated_at'
]
read_only_fields = [
'id', 'created_at', 'updated_at',
'incident_title', 'escalation_policy_name'
]
class NotificationTemplateSerializer(serializers.ModelSerializer):
"""Serializer for NotificationTemplate model"""
class Meta:
model = NotificationTemplate
fields = [
'id', 'name', 'template_type', 'channel_type',
'subject_template', 'body_template', 'variables',
'is_active', 'is_default',
'created_by', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
class SLAInstanceCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating SLA instances"""
class Meta:
model = SLAInstance
fields = [
'sla_definition', 'incident', 'escalation_policy'
]
def create(self, validated_data):
"""Create SLA instance with calculated target time"""
sla_instance = super().create(validated_data)
# Calculate target time based on SLA definition
sla_definition = sla_instance.sla_definition
incident = sla_instance.incident
if sla_definition.business_hours_only and sla_definition.business_hours:
# Calculate target time considering business hours
target_time = sla_definition.business_hours.calculate_target_time(
incident.created_at,
sla_definition.target_duration_minutes
)
else:
# Simple time calculation
target_time = incident.created_at + timedelta(
minutes=sla_definition.target_duration_minutes
)
sla_instance.target_time = target_time
sla_instance.save()
return sla_instance
class OnCallAssignmentCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating on-call assignments"""
class Meta:
model = OnCallAssignment
fields = [
'rotation', 'user', 'start_time', 'end_time', 'handoff_notes'
]
def create(self, validated_data):
"""Create on-call assignment with validation"""
assignment = super().create(validated_data)
# Check for overlapping assignments
overlapping = OnCallAssignment.objects.filter(
rotation=assignment.rotation,
user=assignment.user,
status__in=['SCHEDULED', 'ACTIVE'],
start_time__lt=assignment.end_time,
end_time__gt=assignment.start_time
).exclude(id=assignment.id)
if overlapping.exists():
raise serializers.ValidationError(
"This assignment overlaps with existing assignments for the same user"
)
return assignment

View File

@@ -0,0 +1,196 @@
"""
Signals for SLA & On-Call Management
"""
from django.db import models
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from datetime import timedelta
from sla_oncall.models import (
SLAInstance,
EscalationInstance,
SLADefinition,
EscalationPolicy,
)
from incident_intelligence.models import Incident
@receiver(post_save, sender=Incident)
def create_sla_instances(sender, instance, created, **kwargs):
"""Automatically create SLA instances when incidents are created"""
if created:
# Find applicable SLA definitions
applicable_slas = SLADefinition.objects.filter(
is_active=True,
incident_categories__contains=[instance.category] if instance.category else [],
incident_severities__contains=[instance.severity] if instance.severity else [],
incident_priorities__contains=[instance.priority] if instance.priority else [],
)
# Also check for SLAs that apply to all categories/severities/priorities
all_applicable_slas = SLADefinition.objects.filter(
is_active=True
).filter(
models.Q(incident_categories__isnull=True) | models.Q(incident_categories=[]) |
models.Q(incident_severities__isnull=True) | models.Q(incident_severities=[]) |
models.Q(incident_priorities__isnull=True) | models.Q(incident_priorities=[])
)
# Combine and deduplicate
all_slas = (applicable_slas | all_applicable_slas).distinct()
for sla_definition in all_slas:
if sla_definition.applies_to_incident(instance):
# Find applicable escalation policy
escalation_policy = None
if sla_definition.escalation_enabled:
escalation_policy = EscalationPolicy.objects.filter(
is_active=True,
incident_severities__contains=[instance.severity] if instance.severity else [],
incident_categories__contains=[instance.category] if instance.category else [],
).first()
# Calculate target time
if sla_definition.business_hours_only and sla_definition.business_hours:
target_time = sla_definition.business_hours.calculate_target_time(
instance.created_at,
sla_definition.target_duration_minutes
)
else:
target_time = instance.created_at + timedelta(
minutes=sla_definition.target_duration_minutes
)
# Create SLA instance
SLAInstance.objects.create(
sla_definition=sla_definition,
incident=instance,
target_time=target_time,
escalation_policy=escalation_policy
)
@receiver(pre_save, sender=SLAInstance)
def check_sla_breach(sender, instance, **kwargs):
"""Check for SLA breach and trigger escalation if needed"""
if instance.pk: # Only for existing instances
try:
old_instance = SLAInstance.objects.get(pk=instance.pk)
# Check if SLA just became breached
if (old_instance.status == 'ACTIVE' and
instance.status == 'BREACHED' and
not instance.breached_at):
instance.breached_at = timezone.now()
# Trigger escalation if policy exists and not already triggered
if (instance.escalation_policy and
not instance.escalation_triggered):
trigger_escalation(instance)
except SLAInstance.DoesNotExist:
pass
@receiver(post_save, sender=SLAInstance)
def schedule_escalation_check(sender, instance, created, **kwargs):
"""Schedule escalation check for active SLA instances"""
if instance.status == 'ACTIVE' and instance.escalation_policy:
# Check if escalation should be triggered based on threshold
threshold_time = instance.started_at + timedelta(
minutes=instance.sla_definition.target_duration_minutes *
(instance.escalation_policy.escalation_threshold_percent / 100)
)
if (timezone.now() >= threshold_time and
not instance.escalation_triggered):
trigger_escalation(instance)
def trigger_escalation(sla_instance):
"""Trigger escalation for an SLA instance"""
escalation_policy = sla_instance.escalation_policy
# Create escalation instance
escalation_instance = EscalationInstance.objects.create(
escalation_policy=escalation_policy,
incident=sla_instance.incident,
sla_instance=sla_instance,
status='TRIGGERED',
triggered_at=timezone.now(),
escalation_level=1
)
# Update SLA instance
sla_instance.escalation_triggered = True
sla_instance.escalation_triggered_at = timezone.now()
sla_instance.escalation_level = 1
sla_instance.save()
# Send notifications and perform escalation actions
perform_escalation_actions(escalation_instance)
def perform_escalation_actions(escalation_instance):
"""Perform escalation actions based on policy"""
policy = escalation_instance.escalation_policy
incident = escalation_instance.incident
# Get current on-call person if applicable
current_oncall = get_current_oncall_for_incident(incident)
# Send notifications based on policy configuration
notifications_sent = []
for channel in policy.notification_channels:
notification_data = {
'channel': channel,
'sent_at': timezone.now().isoformat(),
'recipients': get_notification_recipients(channel, current_oncall),
'template': policy.notification_templates.get(channel, {})
}
notifications_sent.append(notification_data)
escalation_instance.notifications_sent = notifications_sent
escalation_instance.save()
# Log escalation action
actions_taken = [{
'action': 'escalation_triggered',
'timestamp': timezone.now().isoformat(),
'level': escalation_instance.escalation_level,
'policy': policy.name
}]
escalation_instance.actions_taken = actions_taken
escalation_instance.save()
def get_current_oncall_for_incident(incident):
"""Get current on-call person for an incident"""
# This is a simplified implementation
# In a real system, you might have more complex logic to determine
# which on-call rotation applies to which incidents
from sla_oncall.models import OnCallAssignment
current_assignment = OnCallAssignment.objects.filter(
status='ACTIVE',
start_time__lte=timezone.now(),
end_time__gte=timezone.now()
).first()
return current_assignment.user if current_assignment else None
def get_notification_recipients(channel, current_oncall):
"""Get notification recipients for a channel"""
recipients = []
if current_oncall:
if channel == 'email' and current_oncall.email:
recipients.append(current_oncall.email)
elif channel == 'sms' and hasattr(current_oncall, 'phone_number'):
recipients.append(getattr(current_oncall, 'phone_number', ''))
return recipients

151
ETB-API/sla_oncall/tests.py Normal file
View File

@@ -0,0 +1,151 @@
"""
Tests for SLA & On-Call Management
"""
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import datetime, timedelta, time
from unittest.mock import patch, MagicMock
from sla_oncall.models import (
BusinessHours,
SLADefinition,
EscalationPolicy,
OnCallRotation,
OnCallAssignment,
SLAInstance,
EscalationInstance,
NotificationTemplate,
)
from incident_intelligence.models import Incident
User = get_user_model()
class BusinessHoursModelTest(TestCase):
"""Test BusinessHours model functionality"""
def setUp(self):
self.business_hours = BusinessHours.objects.create(
name='Test Business Hours',
description='Test business hours',
timezone='UTC',
weekday_start=time(9, 0),
weekday_end=time(17, 0),
weekend_start=time(10, 0),
weekend_end=time(16, 0),
)
def test_business_hours_creation(self):
"""Test business hours creation"""
self.assertEqual(self.business_hours.name, 'Test Business Hours')
self.assertEqual(self.business_hours.weekday_start, time(9, 0))
self.assertTrue(self.business_hours.is_active)
def test_is_business_hours_weekday(self):
"""Test business hours check for weekday"""
# Monday 10 AM UTC
test_time = timezone.make_aware(datetime(2024, 1, 8, 10, 0))
self.assertTrue(self.business_hours.is_business_hours(test_time))
# Monday 6 AM UTC (before business hours)
test_time = timezone.make_aware(datetime(2024, 1, 8, 6, 0))
self.assertFalse(self.business_hours.is_business_hours(test_time))
class SLADefinitionModelTest(TestCase):
"""Test SLADefinition model functionality"""
def setUp(self):
self.business_hours = BusinessHours.objects.create(
name='Test Business Hours',
description='Test business hours',
timezone='UTC',
)
self.sla_definition = SLADefinition.objects.create(
name='Test SLA',
description='Test SLA definition',
sla_type='RESPONSE_TIME',
incident_severities=['HIGH', 'CRITICAL'],
incident_categories=['SYSTEM'],
target_duration_minutes=60,
business_hours=self.business_hours,
)
def test_sla_definition_creation(self):
"""Test SLA definition creation"""
self.assertEqual(self.sla_definition.name, 'Test SLA')
self.assertEqual(self.sla_definition.target_duration_minutes, 60)
self.assertEqual(self.sla_definition.escalation_threshold_percent, 80.0)
class OnCallRotationModelTest(TestCase):
"""Test OnCallRotation model functionality"""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.rotation = OnCallRotation.objects.create(
name='Test Rotation',
description='Test rotation',
rotation_type='WEEKLY',
team_name='Test Team',
)
def test_rotation_creation(self):
"""Test on-call rotation creation"""
self.assertEqual(self.rotation.name, 'Test Rotation')
self.assertEqual(self.rotation.rotation_type, 'WEEKLY')
self.assertEqual(self.rotation.status, 'ACTIVE')
class SLAInstanceModelTest(TestCase):
"""Test SLAInstance model functionality"""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.incident = Incident.objects.create(
title='Test Incident',
description='Test incident description',
severity='HIGH',
category='SYSTEM',
reporter=self.user,
)
self.sla_definition = SLADefinition.objects.create(
name='Test SLA',
description='Test SLA definition',
sla_type='RESPONSE_TIME',
target_duration_minutes=60,
)
self.sla_instance = SLAInstance.objects.create(
sla_definition=self.sla_definition,
incident=self.incident,
target_time=timezone.now() + timedelta(minutes=60),
)
def test_sla_instance_creation(self):
"""Test SLA instance creation"""
self.assertEqual(self.sla_instance.incident, self.incident)
self.assertEqual(self.sla_instance.sla_definition, self.sla_definition)
self.assertEqual(self.sla_instance.status, 'ACTIVE')
def test_is_breached_property(self):
"""Test SLA breach detection"""
# Active SLA with future target time
self.assertFalse(self.sla_instance.is_breached)
# Active SLA with past target time
self.sla_instance.target_time = timezone.now() - timedelta(minutes=1)
self.assertTrue(self.sla_instance.is_breached)

View File

@@ -0,0 +1,33 @@
"""
URL configuration for SLA & On-Call Management API
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from sla_oncall.views.sla import (
BusinessHoursViewSet,
SLADefinitionViewSet,
EscalationPolicyViewSet,
OnCallRotationViewSet,
OnCallAssignmentViewSet,
SLAInstanceViewSet,
EscalationInstanceViewSet,
NotificationTemplateViewSet,
)
# Create router and register viewsets
router = DefaultRouter()
router.register(r'business-hours', BusinessHoursViewSet)
router.register(r'sla-definitions', SLADefinitionViewSet)
router.register(r'escalation-policies', EscalationPolicyViewSet)
router.register(r'oncall-rotations', OnCallRotationViewSet)
router.register(r'oncall-assignments', OnCallAssignmentViewSet)
router.register(r'sla-instances', SLAInstanceViewSet)
router.register(r'escalation-instances', EscalationInstanceViewSet)
router.register(r'notification-templates', NotificationTemplateViewSet)
app_name = 'sla_oncall'
urlpatterns = [
path('api/v1/', include(router.urls)),
]

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -0,0 +1 @@
# Views for SLA & On-Call Management

View File

@@ -0,0 +1,354 @@
"""
Views for SLA & On-Call Management API
"""
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from datetime import datetime, timedelta
from sla_oncall.models import (
BusinessHours,
SLADefinition,
EscalationPolicy,
OnCallRotation,
OnCallAssignment,
SLAInstance,
EscalationInstance,
NotificationTemplate,
)
from sla_oncall.serializers.sla import (
BusinessHoursSerializer,
SLADefinitionSerializer,
EscalationPolicySerializer,
OnCallRotationSerializer,
OnCallAssignmentSerializer,
SLAInstanceSerializer,
EscalationInstanceSerializer,
NotificationTemplateSerializer,
SLAInstanceCreateSerializer,
OnCallAssignmentCreateSerializer,
)
class BusinessHoursViewSet(viewsets.ModelViewSet):
"""ViewSet for BusinessHours management"""
queryset = BusinessHours.objects.all()
serializer_class = BusinessHoursSerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['is_active', 'is_default', 'timezone']
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at']
ordering = ['name']
@action(detail=True, methods=['post'])
def test_business_hours(self, request, pk=None):
"""Test if a given time is within business hours"""
business_hours = self.get_object()
test_time = request.data.get('test_time')
if test_time:
try:
test_datetime = datetime.fromisoformat(test_time.replace('Z', '+00:00'))
is_business_hours = business_hours.is_business_hours(test_datetime)
except ValueError:
return Response(
{'error': 'Invalid datetime format. Use ISO format.'},
status=status.HTTP_400_BAD_REQUEST
)
else:
is_business_hours = business_hours.is_business_hours()
return Response({
'is_business_hours': is_business_hours,
'test_time': test_time or timezone.now().isoformat()
})
class SLADefinitionViewSet(viewsets.ModelViewSet):
"""ViewSet for SLA Definition management"""
queryset = SLADefinition.objects.all()
serializer_class = SLADefinitionSerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['sla_type', 'is_active', 'is_default', 'business_hours_only']
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at', 'target_duration_minutes']
ordering = ['name']
@action(detail=True, methods=['post'])
def test_applicability(self, request, pk=None):
"""Test if SLA definition applies to a given incident"""
sla_definition = self.get_object()
incident_data = request.data
# Create a mock incident object for testing
class MockIncident:
def __init__(self, data):
self.category = data.get('category')
self.severity = data.get('severity')
self.priority = data.get('priority')
mock_incident = MockIncident(incident_data)
applies = sla_definition.applies_to_incident(mock_incident)
return Response({
'applies': applies,
'incident_data': incident_data
})
class EscalationPolicyViewSet(viewsets.ModelViewSet):
"""ViewSet for Escalation Policy management"""
queryset = EscalationPolicy.objects.all()
serializer_class = EscalationPolicySerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['escalation_type', 'trigger_condition', 'is_active']
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at']
ordering = ['name']
class OnCallRotationViewSet(viewsets.ModelViewSet):
"""ViewSet for On-Call Rotation management"""
queryset = OnCallRotation.objects.all()
serializer_class = OnCallRotationSerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['rotation_type', 'status', 'external_system']
search_fields = ['name', 'team_name', 'description']
ordering_fields = ['name', 'created_at']
ordering = ['name']
@action(detail=True, methods=['get'])
def current_oncall(self, request, pk=None):
"""Get current on-call person for this rotation"""
rotation = self.get_object()
current_assignment = rotation.get_current_oncall()
if current_assignment:
serializer = OnCallAssignmentSerializer(current_assignment)
return Response(serializer.data)
else:
return Response({'message': 'No one is currently on-call'})
@action(detail=True, methods=['get'])
def upcoming_assignments(self, request, pk=None):
"""Get upcoming on-call assignments"""
rotation = self.get_object()
days_ahead = int(request.query_params.get('days', 30))
future_time = timezone.now() + timedelta(days=days_ahead)
upcoming = rotation.assignments.filter(
start_time__lte=future_time,
start_time__gte=timezone.now()
).order_by('start_time')
serializer = OnCallAssignmentSerializer(upcoming, many=True)
return Response(serializer.data)
class OnCallAssignmentViewSet(viewsets.ModelViewSet):
"""ViewSet for On-Call Assignment management"""
queryset = OnCallAssignment.objects.all()
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['rotation', 'user', 'status']
search_fields = ['user__username', 'user__email', 'rotation__name']
ordering_fields = ['start_time', 'end_time', 'created_at']
ordering = ['-start_time']
def get_serializer_class(self):
"""Return appropriate serializer based on action"""
if self.action == 'create':
return OnCallAssignmentCreateSerializer
return OnCallAssignmentSerializer
@action(detail=True, methods=['post'])
def handoff(self, request, pk=None):
"""Perform on-call handoff"""
assignment = self.get_object()
handoff_notes = request.data.get('handoff_notes', '')
handed_off_from = request.user
assignment.handoff_notes = handoff_notes
assignment.handed_off_from = handed_off_from
assignment.handoff_time = timezone.now()
assignment.save()
return Response({'message': 'Handoff completed successfully'})
@action(detail=True, methods=['post'])
def activate(self, request, pk=None):
"""Activate on-call assignment"""
assignment = self.get_object()
if assignment.status != 'SCHEDULED':
return Response(
{'error': 'Only scheduled assignments can be activated'},
status=status.HTTP_400_BAD_REQUEST
)
assignment.status = 'ACTIVE'
assignment.save()
return Response({'message': 'Assignment activated'})
@action(detail=True, methods=['post'])
def complete(self, request, pk=None):
"""Complete on-call assignment"""
assignment = self.get_object()
if assignment.status != 'ACTIVE':
return Response(
{'error': 'Only active assignments can be completed'},
status=status.HTTP_400_BAD_REQUEST
)
assignment.status = 'COMPLETED'
assignment.save()
return Response({'message': 'Assignment completed'})
class SLAInstanceViewSet(viewsets.ModelViewSet):
"""ViewSet for SLA Instance management"""
queryset = SLAInstance.objects.all()
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['status', 'escalation_triggered', 'sla_definition']
search_fields = ['incident__title', 'sla_definition__name']
ordering_fields = ['created_at', 'target_time', 'started_at']
ordering = ['-created_at']
def get_serializer_class(self):
"""Return appropriate serializer based on action"""
if self.action == 'create':
return SLAInstanceCreateSerializer
return SLAInstanceSerializer
@action(detail=False, methods=['get'])
def breached(self, request):
"""Get all breached SLA instances"""
breached_slas = self.queryset.filter(
status='BREACHED'
).order_by('-breached_at')
serializer = self.get_serializer(breached_slas, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def at_risk(self, request):
"""Get SLA instances at risk of breaching (within 15 minutes)"""
warning_time = timezone.now() + timedelta(minutes=15)
at_risk_slas = self.queryset.filter(
status='ACTIVE',
target_time__lte=warning_time
).order_by('target_time')
serializer = self.get_serializer(at_risk_slas, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def mark_met(self, request, pk=None):
"""Mark SLA as met"""
sla_instance = self.get_object()
if sla_instance.status != 'ACTIVE':
return Response(
{'error': 'Only active SLA instances can be marked as met'},
status=status.HTTP_400_BAD_REQUEST
)
sla_instance.status = 'MET'
sla_instance.met_at = timezone.now()
sla_instance.save()
return Response({'message': 'SLA marked as met'})
@action(detail=True, methods=['post'])
def mark_breached(self, request, pk=None):
"""Mark SLA as breached"""
sla_instance = self.get_object()
if sla_instance.status != 'ACTIVE':
return Response(
{'error': 'Only active SLA instances can be marked as breached'},
status=status.HTTP_400_BAD_REQUEST
)
sla_instance.status = 'BREACHED'
sla_instance.breached_at = timezone.now()
sla_instance.save()
return Response({'message': 'SLA marked as breached'})
class EscalationInstanceViewSet(viewsets.ModelViewSet):
"""ViewSet for Escalation Instance management"""
queryset = EscalationInstance.objects.all()
serializer_class = EscalationInstanceSerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['status', 'escalation_level', 'escalation_policy']
search_fields = ['incident__title', 'escalation_policy__name']
ordering_fields = ['created_at', 'triggered_at']
ordering = ['-created_at']
@action(detail=True, methods=['post'])
def acknowledge(self, request, pk=None):
"""Acknowledge escalation"""
escalation = self.get_object()
if escalation.status not in ['TRIGGERED', 'PENDING']:
return Response(
{'error': 'Only pending or triggered escalations can be acknowledged'},
status=status.HTTP_400_BAD_REQUEST
)
escalation.status = 'ACKNOWLEDGED'
escalation.acknowledged_at = timezone.now()
escalation.save()
return Response({'message': 'Escalation acknowledged'})
@action(detail=True, methods=['post'])
def resolve(self, request, pk=None):
"""Resolve escalation"""
escalation = self.get_object()
if escalation.status not in ['ACKNOWLEDGED', 'TRIGGERED']:
return Response(
{'error': 'Only acknowledged or triggered escalations can be resolved'},
status=status.HTTP_400_BAD_REQUEST
)
escalation.status = 'RESOLVED'
escalation.resolved_at = timezone.now()
escalation.save()
return Response({'message': 'Escalation resolved'})
class NotificationTemplateViewSet(viewsets.ModelViewSet):
"""ViewSet for Notification Template management"""
queryset = NotificationTemplate.objects.all()
serializer_class = NotificationTemplateSerializer
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['template_type', 'channel_type', 'is_active', 'is_default']
search_fields = ['name', 'subject_template']
ordering_fields = ['name', 'created_at']
ordering = ['template_type', 'channel_type', 'name']