feat: add scheduled scans (cron-like recurring scans)

- New `scheduled_scans` table with daily/weekly/monthly frequencies
- asyncio background scheduler loop checks for due schedules every 60s
- 6 REST endpoints: CRUD + toggle enabled + run-now
- `scheduled_scan_id` FK added to scans table; migrated automatically
- Frontend: Schedules page (list + create form), Schedules nav link,
  "Scheduled" badge on ScanDetails when scan was triggered by a schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 10:48:43 +01:00
parent ef5a27097d
commit 836c8474eb
9 changed files with 1666 additions and 10 deletions

View File

@@ -167,6 +167,29 @@ def _migrate_add_routes_unique_index(conn, verbose=True):
print(" ✅ Migration complete: uq_routes_scan_dest index created")
def _migrate_add_scheduled_scan_id_to_scans(conn, verbose=True):
"""
Migration: add scheduled_scan_id column to scans table.
Existing rows get NULL (manual scans). New column has no inline FK
declaration because SQLite's ALTER TABLE ADD COLUMN doesn't support it;
the relationship is enforced at the application level.
"""
cursor = conn.execute("PRAGMA table_info(scans)")
columns = [row[1] for row in cursor.fetchall()]
if 'scheduled_scan_id' in columns:
return # Already migrated
if verbose:
print(" 🔄 Migrating scans table: adding scheduled_scan_id column...")
conn.execute("ALTER TABLE scans ADD COLUMN scheduled_scan_id INTEGER")
conn.commit()
if verbose:
print(" ✅ Migration complete: scheduled_scan_id column added to scans")
def initialize_database(db_path=None, verbose=True):
"""
Initialize or migrate the database.
@@ -212,6 +235,7 @@ def initialize_database(db_path=None, verbose=True):
# Apply migrations before running schema
_migrate_relax_country_constraint(conn, verbose)
_migrate_add_routes_unique_index(conn, verbose)
_migrate_add_scheduled_scan_id_to_scans(conn, verbose)
# Load and execute schema
schema_sql = load_schema()

View File

@@ -45,6 +45,9 @@ CREATE TABLE IF NOT EXISTS scans (
seat_class TEXT DEFAULT 'economy',
adults INTEGER DEFAULT 1 CHECK(adults > 0 AND adults <= 9),
-- FK to scheduled_scans (NULL for manual scans)
scheduled_scan_id INTEGER,
-- Constraints across columns
CHECK(end_date >= start_date),
CHECK(routes_scanned <= total_routes OR total_routes = 0)
@@ -61,6 +64,10 @@ CREATE INDEX IF NOT EXISTS idx_scans_status
CREATE INDEX IF NOT EXISTS idx_scans_created_at
ON scans(created_at DESC); -- For recent scans query
CREATE INDEX IF NOT EXISTS idx_scans_scheduled_scan_id
ON scans(scheduled_scan_id)
WHERE scheduled_scan_id IS NOT NULL;
-- ============================================================================
-- Table: routes
-- Purpose: Store discovered routes with flight statistics
@@ -244,7 +251,9 @@ ORDER BY created_at ASC;
-- Initial Data: None (tables start empty)
-- ============================================================================
-- ============================================================================
-- Schema version tracking (for future migrations)
-- ============================================================================
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -254,6 +263,64 @@ CREATE TABLE IF NOT EXISTS schema_version (
INSERT OR IGNORE INTO schema_version (version, description)
VALUES (1, 'Initial web app schema with scans and routes tables');
-- ============================================================================
-- Table: scheduled_scans
-- Purpose: Define recurring scan schedules (daily / weekly / monthly)
-- ============================================================================
CREATE TABLE IF NOT EXISTS scheduled_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Scan parameters (same as scans table)
origin TEXT NOT NULL CHECK(length(origin) = 3),
country TEXT NOT NULL CHECK(length(country) >= 2),
window_months INTEGER NOT NULL DEFAULT 1
CHECK(window_months >= 1 AND window_months <= 12),
seat_class TEXT NOT NULL DEFAULT 'economy',
adults INTEGER NOT NULL DEFAULT 1
CHECK(adults > 0 AND adults <= 9),
-- Schedule definition
frequency TEXT NOT NULL
CHECK(frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 6
CHECK(hour >= 0 AND hour <= 23),
minute INTEGER NOT NULL DEFAULT 0
CHECK(minute >= 0 AND minute <= 59),
day_of_week INTEGER CHECK(day_of_week >= 0 AND day_of_week <= 6),
day_of_month INTEGER CHECK(day_of_month >= 1 AND day_of_month <= 28),
-- State
enabled INTEGER NOT NULL DEFAULT 1,
label TEXT,
last_run_at TIMESTAMP,
next_run_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Frequency-specific field requirements
CHECK(
(frequency = 'weekly' AND day_of_week IS NOT NULL) OR
(frequency = 'monthly' AND day_of_month IS NOT NULL) OR
(frequency = 'daily')
)
);
-- Fast lookup of due schedules (partial index on enabled rows only)
CREATE INDEX IF NOT EXISTS idx_scheduled_scans_next_run
ON scheduled_scans(next_run_at)
WHERE enabled = 1;
-- Auto-update updated_at on every PATCH
CREATE TRIGGER IF NOT EXISTS update_scheduled_scans_timestamp
AFTER UPDATE ON scheduled_scans
FOR EACH ROW BEGIN
UPDATE scheduled_scans SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
INSERT OR IGNORE INTO schema_version (version, description)
VALUES (2, 'Add scheduled_scans table');
-- ============================================================================
-- Verification Queries (for testing)
-- ============================================================================