From d68d9133c1f59562684e9bc59e5faa54d9f3b449 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 14:33:48 -0600 Subject: [PATCH] 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 --- docs/ai/PHASE3.md | 10 +- tests/test_schedule_api.py | 639 +++++++++++++++++++++++++++++ web/api/schedules.py | 269 +++++++++--- web/jobs/scan_job.py | 8 +- web/routes/main.py | 56 +++ web/services/scan_service.py | 8 +- web/services/schedule_service.py | 17 +- web/services/scheduler_service.py | 96 +++-- web/templates/base.html | 4 + web/templates/schedule_create.html | 427 +++++++++++++++++++ web/templates/schedule_edit.html | 569 +++++++++++++++++++++++++ web/templates/schedules.html | 389 ++++++++++++++++++ web/utils/validators.py | 8 +- 13 files changed, 2413 insertions(+), 87 deletions(-) create mode 100644 tests/test_schedule_api.py create mode 100644 web/templates/schedule_create.html create mode 100644 web/templates/schedule_edit.html create mode 100644 web/templates/schedules.html diff --git a/docs/ai/PHASE3.md b/docs/ai/PHASE3.md index c04afbe..f874293 100644 --- a/docs/ai/PHASE3.md +++ b/docs/ai/PHASE3.md @@ -1,16 +1,16 @@ # Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans -**Status:** Ready to Start -**Progress:** 0/14 days complete (0%) +**Status:** In Progress +**Progress:** 5/14 days complete (36%) **Estimated Duration:** 14 days (2 weeks) **Dependencies:** Phase 2 Complete ✅ ## Progress Summary - ✅ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE -- 📋 **Step 2: ScheduleService Implementation** (Days 2-3) - NEXT -- 📋 **Step 3: Schedules API Endpoints** (Days 4-5) -- 📋 **Step 4: Schedule Management UI** (Days 6-7) +- ✅ **Step 2: ScheduleService Implementation** (Days 2-3) - COMPLETE +- ✅ **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE +- 📋 **Step 4: Schedule Management UI** (Days 6-7) - NEXT - 📋 **Step 5: Enhanced Dashboard with Charts** (Days 8-9) - 📋 **Step 6: Scheduler Integration** (Day 10) - 📋 **Step 7: Scan Comparison Features** (Days 11-12) diff --git a/tests/test_schedule_api.py b/tests/test_schedule_api.py new file mode 100644 index 0000000..7601986 --- /dev/null +++ b/tests/test_schedule_api.py @@ -0,0 +1,639 @@ +""" +Integration tests for Schedule API endpoints. + +Tests all schedule management endpoints including creating, listing, +updating, deleting schedules, and manually triggering scheduled scans. +""" + +import json +import pytest +from datetime import datetime + +from web.models import Schedule, Scan + + +@pytest.fixture +def sample_schedule(db, sample_config_file): + """ + Create a sample schedule in the database for testing. + + Args: + db: Database session fixture + sample_config_file: Path to test config file + + Returns: + Schedule model instance + """ + schedule = Schedule( + name='Daily Test Scan', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + last_run=None, + next_run=datetime(2025, 11, 15, 2, 0, 0), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + db.add(schedule) + db.commit() + db.refresh(schedule) + + return schedule + + +class TestScheduleAPIEndpoints: + """Test suite for schedule API endpoints.""" + + def test_list_schedules_empty(self, client, db): + """Test listing schedules when database is empty.""" + response = client.get('/api/schedules') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['schedules'] == [] + assert data['total'] == 0 + assert data['page'] == 1 + assert data['per_page'] == 20 + + def test_list_schedules_populated(self, client, db, sample_schedule): + """Test listing schedules with existing data.""" + response = client.get('/api/schedules') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 1 + assert len(data['schedules']) == 1 + assert data['schedules'][0]['id'] == sample_schedule.id + assert data['schedules'][0]['name'] == sample_schedule.name + assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression + + def test_list_schedules_pagination(self, client, db, sample_config_file): + """Test schedule list pagination.""" + # Create 25 schedules + for i in range(25): + schedule = Schedule( + name=f'Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + created_at=datetime.utcnow() + ) + db.add(schedule) + db.commit() + + # Test page 1 + response = client.get('/api/schedules?page=1&per_page=10') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 25 + assert len(data['schedules']) == 10 + assert data['page'] == 1 + assert data['per_page'] == 10 + assert data['pages'] == 3 + + # Test page 2 + response = client.get('/api/schedules?page=2&per_page=10') + assert response.status_code == 200 + + data = json.loads(response.data) + assert len(data['schedules']) == 10 + assert data['page'] == 2 + + def test_list_schedules_filter_enabled(self, client, db, sample_config_file): + """Test filtering schedules by enabled status.""" + # Create enabled and disabled schedules + for i in range(3): + schedule = Schedule( + name=f'Enabled Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + created_at=datetime.utcnow() + ) + db.add(schedule) + + for i in range(2): + schedule = Schedule( + name=f'Disabled Schedule {i}', + config_file=sample_config_file, + cron_expression='0 3 * * *', + enabled=False, + created_at=datetime.utcnow() + ) + db.add(schedule) + db.commit() + + # Filter by enabled=true + response = client.get('/api/schedules?enabled=true') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 3 + for schedule in data['schedules']: + assert schedule['enabled'] is True + + # Filter by enabled=false + response = client.get('/api/schedules?enabled=false') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['total'] == 2 + for schedule in data['schedules']: + assert schedule['enabled'] is False + + def test_get_schedule(self, client, db, sample_schedule): + """Test getting schedule details.""" + response = client.get(f'/api/schedules/{sample_schedule.id}') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['id'] == sample_schedule.id + assert data['name'] == sample_schedule.name + assert data['config_file'] == sample_schedule.config_file + assert data['cron_expression'] == sample_schedule.cron_expression + assert data['enabled'] == sample_schedule.enabled + assert 'history' in data + + def test_get_schedule_not_found(self, client, db): + """Test getting non-existent schedule.""" + response = client.get('/api/schedules/99999') + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + assert 'not found' in data['error'].lower() + + def test_create_schedule(self, client, db, sample_config_file): + """Test creating a new schedule.""" + schedule_data = { + 'name': 'New Test Schedule', + 'config_file': sample_config_file, + 'cron_expression': '0 3 * * *', + 'enabled': True + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201 + + data = json.loads(response.data) + assert 'schedule_id' in data + assert data['message'] == 'Schedule created successfully' + assert 'schedule' in data + + # Verify schedule in database + schedule = db.query(Schedule).filter(Schedule.id == data['schedule_id']).first() + assert schedule is not None + assert schedule.name == schedule_data['name'] + assert schedule.cron_expression == schedule_data['cron_expression'] + + def test_create_schedule_missing_fields(self, client, db): + """Test creating schedule with missing required fields.""" + # Missing cron_expression + schedule_data = { + 'name': 'Incomplete Schedule', + 'config_file': '/app/configs/test.yaml' + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + assert 'missing' in data['error'].lower() + + def test_create_schedule_invalid_cron(self, client, db, sample_config_file): + """Test creating schedule with invalid cron expression.""" + schedule_data = { + 'name': 'Invalid Cron Schedule', + 'config_file': sample_config_file, + 'cron_expression': 'invalid cron' + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower() + + def test_create_schedule_invalid_config(self, client, db): + """Test creating schedule with non-existent config file.""" + schedule_data = { + 'name': 'Invalid Config Schedule', + 'config_file': '/nonexistent/config.yaml', + 'cron_expression': '0 2 * * *' + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + assert 'not found' in data['error'].lower() + + def test_update_schedule(self, client, db, sample_schedule): + """Test updating schedule fields.""" + update_data = { + 'name': 'Updated Schedule Name', + 'cron_expression': '0 4 * * *' + } + + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['message'] == 'Schedule updated successfully' + assert data['schedule']['name'] == update_data['name'] + assert data['schedule']['cron_expression'] == update_data['cron_expression'] + + # Verify in database + db.refresh(sample_schedule) + assert sample_schedule.name == update_data['name'] + assert sample_schedule.cron_expression == update_data['cron_expression'] + + def test_update_schedule_not_found(self, client, db): + """Test updating non-existent schedule.""" + update_data = {'name': 'New Name'} + + response = client.put( + '/api/schedules/99999', + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + + def test_update_schedule_invalid_cron(self, client, db, sample_schedule): + """Test updating schedule with invalid cron expression.""" + update_data = {'cron_expression': 'invalid'} + + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps(update_data), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + + def test_update_schedule_toggle_enabled(self, client, db, sample_schedule): + """Test enabling/disabling schedule.""" + # Disable schedule + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps({'enabled': False}), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['schedule']['enabled'] is False + + # Enable schedule + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps({'enabled': True}), + content_type='application/json' + ) + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['schedule']['enabled'] is True + + def test_update_schedule_no_data(self, client, db, sample_schedule): + """Test updating schedule with no data.""" + response = client.put( + f'/api/schedules/{sample_schedule.id}', + data=json.dumps({}), + content_type='application/json' + ) + assert response.status_code == 400 + + data = json.loads(response.data) + assert 'error' in data + + def test_delete_schedule(self, client, db, sample_schedule): + """Test deleting a schedule.""" + schedule_id = sample_schedule.id + + response = client.delete(f'/api/schedules/{schedule_id}') + assert response.status_code == 200 + + data = json.loads(response.data) + assert data['message'] == 'Schedule deleted successfully' + assert data['schedule_id'] == schedule_id + + # Verify deletion in database + schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first() + assert schedule is None + + def test_delete_schedule_not_found(self, client, db): + """Test deleting non-existent schedule.""" + response = client.delete('/api/schedules/99999') + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + + def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_config_file): + """Test that deleting schedule preserves associated scans.""" + # Create a scan associated with the schedule + scan = Scan( + timestamp=datetime.utcnow(), + status='completed', + config_file=sample_config_file, + title='Test Scan', + triggered_by='scheduled', + schedule_id=sample_schedule.id + ) + db.add(scan) + db.commit() + scan_id = scan.id + + # Delete schedule + response = client.delete(f'/api/schedules/{sample_schedule.id}') + assert response.status_code == 200 + + # Verify scan still exists + scan = db.query(Scan).filter(Scan.id == scan_id).first() + assert scan is not None + assert scan.schedule_id is None # Schedule ID becomes null + + def test_trigger_schedule(self, client, db, sample_schedule): + """Test manually triggering a scheduled scan.""" + response = client.post(f'/api/schedules/{sample_schedule.id}/trigger') + assert response.status_code == 201 + + data = json.loads(response.data) + assert data['message'] == 'Scan triggered successfully' + assert 'scan_id' in data + assert data['schedule_id'] == sample_schedule.id + + # Verify scan was created + scan = db.query(Scan).filter(Scan.id == data['scan_id']).first() + assert scan is not None + assert scan.triggered_by == 'manual' + assert scan.schedule_id == sample_schedule.id + assert scan.config_file == sample_schedule.config_file + + def test_trigger_schedule_not_found(self, client, db): + """Test triggering non-existent schedule.""" + response = client.post('/api/schedules/99999/trigger') + assert response.status_code == 404 + + data = json.loads(response.data) + assert 'error' in data + + def test_get_schedule_with_history(self, client, db, sample_schedule, sample_config_file): + """Test getting schedule includes execution history.""" + # Create some scans for this schedule + for i in range(5): + scan = Scan( + timestamp=datetime.utcnow(), + status='completed', + config_file=sample_config_file, + title=f'Scheduled Scan {i}', + triggered_by='scheduled', + schedule_id=sample_schedule.id + ) + db.add(scan) + db.commit() + + response = client.get(f'/api/schedules/{sample_schedule.id}') + assert response.status_code == 200 + + data = json.loads(response.data) + assert 'history' in data + assert len(data['history']) == 5 + + def test_schedule_workflow_integration(self, client, db, sample_config_file): + """Test complete schedule workflow: create → update → trigger → delete.""" + # 1. Create schedule + schedule_data = { + 'name': 'Integration Test Schedule', + 'config_file': sample_config_file, + 'cron_expression': '0 2 * * *', + 'enabled': True + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201 + schedule_id = json.loads(response.data)['schedule_id'] + + # 2. Get schedule + response = client.get(f'/api/schedules/{schedule_id}') + assert response.status_code == 200 + + # 3. Update schedule + response = client.put( + f'/api/schedules/{schedule_id}', + data=json.dumps({'name': 'Updated Integration Test'}), + content_type='application/json' + ) + assert response.status_code == 200 + + # 4. Trigger schedule + response = client.post(f'/api/schedules/{schedule_id}/trigger') + assert response.status_code == 201 + scan_id = json.loads(response.data)['scan_id'] + + # 5. Verify scan was created + scan = db.query(Scan).filter(Scan.id == scan_id).first() + assert scan is not None + + # 6. Delete schedule + response = client.delete(f'/api/schedules/{schedule_id}') + assert response.status_code == 200 + + # 7. Verify schedule deleted + response = client.get(f'/api/schedules/{schedule_id}') + assert response.status_code == 404 + + # 8. Verify scan still exists + scan = db.query(Scan).filter(Scan.id == scan_id).first() + assert scan is not None + + def test_list_schedules_ordering(self, client, db, sample_config_file): + """Test that schedules are ordered by next_run time.""" + # Create schedules with different next_run times + schedules = [] + for i in range(3): + schedule = Schedule( + name=f'Schedule {i}', + config_file=sample_config_file, + cron_expression='0 2 * * *', + enabled=True, + next_run=datetime(2025, 11, 15 + i, 2, 0, 0), + created_at=datetime.utcnow() + ) + db.add(schedule) + schedules.append(schedule) + + # Create a disabled schedule (next_run is None) + disabled_schedule = Schedule( + name='Disabled Schedule', + config_file=sample_config_file, + cron_expression='0 3 * * *', + enabled=False, + next_run=None, + created_at=datetime.utcnow() + ) + db.add(disabled_schedule) + db.commit() + + response = client.get('/api/schedules') + assert response.status_code == 200 + + data = json.loads(response.data) + returned_schedules = data['schedules'] + + # Schedules with next_run should come before those without + # Within those with next_run, they should be ordered by time + assert returned_schedules[0]['id'] == schedules[0].id + assert returned_schedules[1]['id'] == schedules[1].id + assert returned_schedules[2]['id'] == schedules[2].id + assert returned_schedules[3]['id'] == disabled_schedule.id + + def test_create_schedule_with_disabled(self, client, db, sample_config_file): + """Test creating a disabled schedule.""" + schedule_data = { + 'name': 'Disabled Schedule', + 'config_file': sample_config_file, + 'cron_expression': '0 2 * * *', + 'enabled': False + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201 + + data = json.loads(response.data) + assert data['schedule']['enabled'] is False + assert data['schedule']['next_run'] is None # Disabled schedules have no next_run + + +class TestScheduleAPIAuthentication: + """Test suite for schedule API authentication.""" + + def test_schedules_require_authentication(self, app): + """Test that all schedule endpoints require authentication.""" + # Create unauthenticated client + client = app.test_client() + + endpoints = [ + ('GET', '/api/schedules'), + ('GET', '/api/schedules/1'), + ('POST', '/api/schedules'), + ('PUT', '/api/schedules/1'), + ('DELETE', '/api/schedules/1'), + ('POST', '/api/schedules/1/trigger') + ] + + for method, endpoint in endpoints: + if method == 'GET': + response = client.get(endpoint) + elif method == 'POST': + response = client.post( + endpoint, + data=json.dumps({}), + content_type='application/json' + ) + elif method == 'PUT': + response = client.put( + endpoint, + data=json.dumps({}), + content_type='application/json' + ) + elif method == 'DELETE': + response = client.delete(endpoint) + + # Should redirect to login or return 401 + assert response.status_code in [302, 401], \ + f"{method} {endpoint} should require authentication" + + +class TestScheduleAPICronValidation: + """Test suite for cron expression validation.""" + + def test_valid_cron_expressions(self, client, db, sample_config_file): + """Test various valid cron expressions.""" + valid_expressions = [ + '0 2 * * *', # Daily at 2am + '*/15 * * * *', # Every 15 minutes + '0 0 * * 0', # Weekly on Sunday + '0 0 1 * *', # Monthly on 1st + '0 */4 * * *', # Every 4 hours + ] + + for cron_expr in valid_expressions: + schedule_data = { + 'name': f'Schedule for {cron_expr}', + 'config_file': sample_config_file, + 'cron_expression': cron_expr + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 201, \ + f"Valid cron expression '{cron_expr}' should be accepted" + + def test_invalid_cron_expressions(self, client, db, sample_config_file): + """Test various invalid cron expressions.""" + invalid_expressions = [ + 'invalid', + '60 2 * * *', # Invalid minute + '0 25 * * *', # Invalid hour + '0 0 32 * *', # Invalid day + '0 0 * 13 *', # Invalid month + '0 0 * * 8', # Invalid day of week + ] + + for cron_expr in invalid_expressions: + schedule_data = { + 'name': f'Schedule for {cron_expr}', + 'config_file': sample_config_file, + 'cron_expression': cron_expr + } + + response = client.post( + '/api/schedules', + data=json.dumps(schedule_data), + content_type='application/json' + ) + assert response.status_code == 400, \ + f"Invalid cron expression '{cron_expr}' should be rejected" diff --git a/web/api/schedules.py b/web/api/schedules.py index 6fa11e8..a21cdd8 100644 --- a/web/api/schedules.py +++ b/web/api/schedules.py @@ -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('/', 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('/', 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('/', 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('//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 diff --git a/web/jobs/scan_job.py b/web/jobs/scan_job.py index e8aee3e..a01fac9 100644 --- a/web/jobs/scan_job.py +++ b/web/jobs/scan_job.py @@ -62,8 +62,14 @@ def execute_scan(scan_id: int, config_file: str, db_url: str): logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}") + # Convert config_file to full path if it's just a filename + if not config_file.startswith('/'): + config_path = f'/app/configs/{config_file}' + else: + config_path = config_file + # Initialize scanner - scanner = SneakyScanner(config_file) + scanner = SneakyScanner(config_path) # Execute scan logger.info(f"Scan {scan_id}: Running scanner...") diff --git a/web/routes/main.py b/web/routes/main.py index 34c7796..87694b9 100644 --- a/web/routes/main.py +++ b/web/routes/main.py @@ -66,3 +66,59 @@ def scan_detail(scan_id): """ # TODO: Phase 5 - Implement scan detail page return render_template('scan_detail.html', scan_id=scan_id) + + +@bp.route('/schedules') +@login_required +def schedules(): + """ + Schedules list page - shows all scheduled scans. + + Returns: + Rendered schedules list template + """ + return render_template('schedules.html') + + +@bp.route('/schedules/create') +@login_required +def create_schedule(): + """ + Create new schedule form page. + + Returns: + Rendered schedule create template with available config files + """ + import os + + # Get list of available config files + configs_dir = '/app/configs' + config_files = [] + + try: + if os.path.exists(configs_dir): + config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')] + config_files.sort() + except Exception as e: + logger.error(f"Error listing config files: {e}") + + return render_template('schedule_create.html', config_files=config_files) + + +@bp.route('/schedules//edit') +@login_required +def edit_schedule(schedule_id): + """ + Edit existing schedule form page. + + Args: + schedule_id: Schedule ID to edit + + Returns: + Rendered schedule edit template + """ + from flask import flash + + # Note: Schedule data is loaded via AJAX in the template + # This just renders the page with the schedule_id in the URL + return render_template('schedule_edit.html', schedule_id=schedule_id) diff --git a/web/services/scan_service.py b/web/services/scan_service.py index 80fe6f9..85a82ce 100644 --- a/web/services/scan_service.py +++ b/web/services/scan_service.py @@ -66,9 +66,15 @@ class ScanService: if not is_valid: raise ValueError(f"Invalid config file: {error_msg}") + # Convert config_file to full path if it's just a filename + if not config_file.startswith('/'): + config_path = f'/app/configs/{config_file}' + else: + config_path = config_file + # Load config to get title import yaml - with open(config_file, 'r') as f: + with open(config_path, 'r') as f: config = yaml.safe_load(f) # Create scan record diff --git a/web/services/schedule_service.py b/web/services/schedule_service.py index 3c84874..a23d981 100644 --- a/web/services/schedule_service.py +++ b/web/services/schedule_service.py @@ -64,7 +64,13 @@ class ScheduleService: raise ValueError(f"Invalid cron expression: {error_msg}") # Validate config file exists - if not os.path.isfile(config_file): + # If config_file is just a filename, prepend the configs directory + if not config_file.startswith('/'): + config_file_path = os.path.join('/app/configs', config_file) + else: + config_file_path = config_file + + if not os.path.isfile(config_file_path): raise ValueError(f"Config file not found: {config_file}") # Calculate next run time @@ -196,7 +202,14 @@ class ScheduleService: # Validate config file if being updated if 'config_file' in updates: - if not os.path.isfile(updates['config_file']): + config_file = updates['config_file'] + # If config_file is just a filename, prepend the configs directory + if not config_file.startswith('/'): + config_file_path = os.path.join('/app/configs', config_file) + else: + config_file_path = config_file + + if not os.path.isfile(config_file_path): raise ValueError(f"Config file not found: {updates['config_file']}") # Handle enabled toggle diff --git a/web/services/scheduler_service.py b/web/services/scheduler_service.py index ce16687..e336270 100644 --- a/web/services/scheduler_service.py +++ b/web/services/scheduler_service.py @@ -136,35 +136,27 @@ class SchedulerService: Raises: RuntimeError: If scheduler not initialized ValueError: If cron expression is invalid - - Note: - This is a placeholder for Phase 3 scheduled scanning feature. - Currently not used, but structure is in place. """ if not self.scheduler: raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.") - # Parse cron expression - # Format: "minute hour day month day_of_week" - parts = cron_expression.split() - if len(parts) != 5: - raise ValueError(f"Invalid cron expression: {cron_expression}") + from apscheduler.triggers.cron import CronTrigger - minute, hour, day, month, day_of_week = parts + # Create cron trigger from expression + try: + trigger = CronTrigger.from_crontab(cron_expression) + except (ValueError, KeyError) as e: + raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}") - # Add cron job (currently placeholder - will be enhanced in Phase 3) + # Add cron job job = self.scheduler.add_job( func=self._trigger_scheduled_scan, - args=[schedule_id, config_file], - trigger='cron', - minute=minute, - hour=hour, - day=day, - month=month, - day_of_week=day_of_week, + args=[schedule_id], + trigger=trigger, id=f'schedule_{schedule_id}', name=f'Schedule {schedule_id}', - replace_existing=True + replace_existing=True, + max_instances=1 # Only one instance per schedule ) logger.info(f"Added scheduled scan {schedule_id} with cron '{cron_expression}' (job_id={job.id})") @@ -191,7 +183,7 @@ class SchedulerService: except Exception as e: logger.warning(f"Failed to remove scheduled scan job {job_id}: {str(e)}") - def _trigger_scheduled_scan(self, schedule_id: int, config_file: str): + def _trigger_scheduled_scan(self, schedule_id: int): """ Internal method to trigger a scan from a schedule. @@ -199,17 +191,63 @@ class SchedulerService: Args: schedule_id: Database ID of the schedule - config_file: Path to YAML configuration file - - Note: - This will be fully implemented in Phase 3 when scheduled - scanning is added. Currently a placeholder. """ logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}") - # TODO: In Phase 3, this will: - # 1. Create a new Scan record with triggered_by='scheduled' - # 2. Call queue_scan() with the new scan_id - # 3. Update schedule's last_run and next_run timestamps + + # Import here to avoid circular imports + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from web.services.schedule_service import ScheduleService + from web.services.scan_service import ScanService + + try: + # Create database session + engine = create_engine(self.db_url) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Get schedule details + schedule_service = ScheduleService(session) + schedule = schedule_service.get_schedule(schedule_id) + + if not schedule: + logger.error(f"Schedule {schedule_id} not found") + return + + if not schedule['enabled']: + logger.warning(f"Schedule {schedule_id} is disabled, skipping execution") + return + + # Create and trigger scan + scan_service = ScanService(session) + scan_id = scan_service.trigger_scan( + config_file=schedule['config_file'], + triggered_by='scheduled', + schedule_id=schedule_id, + scheduler=None # Don't pass scheduler to avoid recursion + ) + + # Queue the scan for execution + self.queue_scan(scan_id, schedule['config_file']) + + # Update schedule's last_run and next_run + from croniter import croniter + next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime) + + schedule_service.update_run_times( + schedule_id=schedule_id, + last_run=datetime.utcnow(), + next_run=next_run + ) + + logger.info(f"Scheduled scan completed: schedule_id={schedule_id}, scan_id={scan_id}") + + finally: + session.close() + + except Exception as e: + logger.error(f"Error triggering scheduled scan {schedule_id}: {str(e)}", exc_info=True) def get_job_status(self, job_id: str) -> Optional[dict]: """ diff --git a/web/templates/base.html b/web/templates/base.html index 4e2a999..e8c1ce3 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -49,6 +49,10 @@ Scans +