Phase 3 Steps 3 & 4: Complete Schedules API & Management UI

Implemented full schedule management system with API endpoints and
user interface for creating, editing, and managing scheduled scans.

API Implementation:
- Implemented all 6 schedules API endpoints (list, get, create, update, delete, trigger)
- Added comprehensive error handling and validation
- Integrated with ScheduleService and SchedulerService
- Added manual trigger endpoint for on-demand execution

Schedule Management UI:
- Created schedules list page with stats cards and enable/disable toggles
- Built schedule creation form with cron expression builder and quick templates
- Implemented schedule edit page with execution history
- Added "Schedules" navigation link to main menu
- Real-time validation and human-readable cron descriptions

Config File Path Resolution:
- Fixed config file path handling to support relative filenames
- Updated validators.py to resolve relative paths to /app/configs/
- Modified schedule_service.py, scan_service.py, and scan_job.py for consistency
- Ensures UI can use simple filenames while backend uses absolute paths

Scheduler Integration:
- Completed scheduled scan execution in scheduler_service.py
- Added cron job management with APScheduler
- Implemented automatic schedule loading on startup
- Updated run times after each execution

Testing:
- Added comprehensive API integration tests (test_schedule_api.py)
- 22+ test cases covering all endpoints and workflows

Progress: Phase 3 Steps 1-4 complete (36% - 5/14 days)
Next: Step 5 - Enhanced Dashboard with Charts
This commit is contained in:
2025-11-14 14:33:48 -06:00
parent 7969068c36
commit d68d9133c1
13 changed files with 2413 additions and 87 deletions

View File

@@ -5,9 +5,15 @@ Handles endpoints for managing scheduled scans including CRUD operations
and manual triggering.
"""
from flask import Blueprint, jsonify, request
import logging
from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required
from web.services.schedule_service import ScheduleService
from web.services.scan_service import ScanService
logger = logging.getLogger(__name__)
bp = Blueprint('schedules', __name__)
@@ -16,16 +22,36 @@ bp = Blueprint('schedules', __name__)
@api_auth_required
def list_schedules():
"""
List all schedules.
List all schedules with pagination and filtering.
Query parameters:
page: Page number (default: 1)
per_page: Items per page (default: 20)
enabled: Filter by enabled status (true/false)
Returns:
JSON response with schedules list
JSON response with paginated schedules list
"""
# TODO: Implement in Phase 3
return jsonify({
'schedules': [],
'message': 'Schedules list endpoint - to be implemented in Phase 3'
})
try:
# Parse query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
enabled_str = request.args.get('enabled', type=str)
# Parse enabled filter
enabled_filter = None
if enabled_str is not None:
enabled_filter = enabled_str.lower() == 'true'
# Get schedules
schedule_service = ScheduleService(current_app.db_session)
result = schedule_service.list_schedules(page, per_page, enabled_filter)
return jsonify(result), 200
except Exception as e:
logger.error(f"Error listing schedules: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['GET'])
@@ -38,13 +64,20 @@ def get_schedule(schedule_id):
schedule_id: Schedule ID
Returns:
JSON response with schedule details
JSON response with schedule details including execution history
"""
# TODO: Implement in Phase 3
return jsonify({
'schedule_id': schedule_id,
'message': 'Schedule detail endpoint - to be implemented in Phase 3'
})
try:
schedule_service = ScheduleService(current_app.db_session)
schedule = schedule_service.get_schedule(schedule_id)
return jsonify(schedule), 200
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error getting schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('', methods=['POST'])
@@ -54,22 +87,60 @@ def create_schedule():
Create a new schedule.
Request body:
name: Schedule name
config_file: Path to YAML config
cron_expression: Cron-like schedule expression
name: Schedule name (required)
config_file: Path to YAML config (required)
cron_expression: Cron expression (required, e.g., '0 2 * * *')
enabled: Whether schedule is active (optional, default: true)
Returns:
JSON response with created schedule ID
"""
# TODO: Implement in Phase 3
data = request.get_json() or {}
try:
data = request.get_json() or {}
return jsonify({
'schedule_id': None,
'status': 'not_implemented',
'message': 'Schedule creation endpoint - to be implemented in Phase 3',
'data': data
}), 501
# Validate required fields
required = ['name', 'config_file', 'cron_expression']
missing = [field for field in required if field not in data]
if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
# Create schedule
schedule_service = ScheduleService(current_app.db_session)
schedule_id = schedule_service.create_schedule(
name=data['name'],
config_file=data['config_file'],
cron_expression=data['cron_expression'],
enabled=data.get('enabled', True)
)
# Get the created schedule
schedule = schedule_service.get_schedule(schedule_id)
# Add to APScheduler if enabled
if schedule['enabled'] and hasattr(current_app, 'scheduler'):
try:
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=schedule['config_file'],
cron_expression=schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} added to APScheduler")
except Exception as e:
logger.error(f"Failed to add schedule {schedule_id} to APScheduler: {str(e)}")
# Continue anyway - schedule is created in DB
return jsonify({
'schedule_id': schedule_id,
'message': 'Schedule created successfully',
'schedule': schedule
}), 201
except ValueError as e:
# Validation error
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error creating schedule: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['PUT'])
@@ -84,21 +155,73 @@ def update_schedule(schedule_id):
Request body:
name: Schedule name (optional)
config_file: Path to YAML config (optional)
cron_expression: Cron-like schedule expression (optional)
cron_expression: Cron expression (optional)
enabled: Whether schedule is active (optional)
Returns:
JSON response with update status
JSON response with updated schedule
"""
# TODO: Implement in Phase 3
data = request.get_json() or {}
try:
data = request.get_json() or {}
return jsonify({
'schedule_id': schedule_id,
'status': 'not_implemented',
'message': 'Schedule update endpoint - to be implemented in Phase 3',
'data': data
}), 501
if not data:
return jsonify({'error': 'No update data provided'}), 400
# Update schedule
schedule_service = ScheduleService(current_app.db_session)
# Store old state to check if scheduler update needed
old_schedule = schedule_service.get_schedule(schedule_id)
# Perform update
updated_schedule = schedule_service.update_schedule(schedule_id, **data)
# Update in APScheduler if needed
if hasattr(current_app, 'scheduler'):
try:
# If cron expression or config changed, or enabled status changed
cron_changed = 'cron_expression' in data
config_changed = 'config_file' in data
enabled_changed = 'enabled' in data
if enabled_changed:
if updated_schedule['enabled']:
# Re-add to scheduler (replaces existing)
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=updated_schedule['config_file'],
cron_expression=updated_schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
else:
# Remove from scheduler
current_app.scheduler.remove_scheduled_scan(schedule_id)
logger.info(f"Schedule {schedule_id} disabled and removed from APScheduler")
elif (cron_changed or config_changed) and updated_schedule['enabled']:
# Reload schedule in APScheduler
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=updated_schedule['config_file'],
cron_expression=updated_schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
except Exception as e:
logger.error(f"Failed to update schedule {schedule_id} in APScheduler: {str(e)}")
# Continue anyway - schedule is updated in DB
return jsonify({
'message': 'Schedule updated successfully',
'schedule': updated_schedule
}), 200
except ValueError as e:
# Schedule not found or validation error
if 'not found' in str(e):
return jsonify({'error': str(e)}), 404
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error updating schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['DELETE'])
@@ -107,18 +230,40 @@ def delete_schedule(schedule_id):
"""
Delete a schedule.
Note: Associated scans are NOT deleted (schedule_id becomes null).
Active scans will complete normally.
Args:
schedule_id: Schedule ID to delete
Returns:
JSON response with deletion status
"""
# TODO: Implement in Phase 3
return jsonify({
'schedule_id': schedule_id,
'status': 'not_implemented',
'message': 'Schedule deletion endpoint - to be implemented in Phase 3'
}), 501
try:
# Remove from APScheduler first
if hasattr(current_app, 'scheduler'):
try:
current_app.scheduler.remove_scheduled_scan(schedule_id)
logger.info(f"Schedule {schedule_id} removed from APScheduler")
except Exception as e:
logger.warning(f"Failed to remove schedule {schedule_id} from APScheduler: {str(e)}")
# Continue anyway
# Delete from database
schedule_service = ScheduleService(current_app.db_session)
schedule_service.delete_schedule(schedule_id)
return jsonify({
'message': 'Schedule deleted successfully',
'schedule_id': schedule_id
}), 200
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error deleting schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
@@ -127,19 +272,47 @@ def trigger_schedule(schedule_id):
"""
Manually trigger a scheduled scan.
Creates a new scan with the schedule's configuration and queues it
for immediate execution.
Args:
schedule_id: Schedule ID to trigger
Returns:
JSON response with triggered scan ID
"""
# TODO: Implement in Phase 3
return jsonify({
'schedule_id': schedule_id,
'scan_id': None,
'status': 'not_implemented',
'message': 'Manual schedule trigger endpoint - to be implemented in Phase 3'
}), 501
try:
# Get schedule
schedule_service = ScheduleService(current_app.db_session)
schedule = schedule_service.get_schedule(schedule_id)
# Trigger scan
scan_service = ScanService(current_app.db_session)
# Get scheduler if available
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'],
triggered_by='manual',
schedule_id=schedule_id,
scheduler=scheduler
)
logger.info(f"Manual trigger of schedule {schedule_id} created scan {scan_id}")
return jsonify({
'message': 'Scan triggered successfully',
'schedule_id': schedule_id,
'scan_id': scan_id
}), 201
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error triggering schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
# Health check endpoint