feat: implement reverse scan (country → specific airports)
All checks were successful
Deploy / deploy (push) Successful in 30s
All checks were successful
Deploy / deploy (push) Successful in 30s
- DB schema: relaxed origin CHECK to >=2 chars, added scan_mode column to
scans and scheduled_scans, added origin_airport to routes and flights,
updated unique index to (scan_id, COALESCE(origin_airport,''), destination)
- Migrations: init_db.py recreates tables and adds columns via guarded ALTERs
- API: scan_mode field on ScanRequest/Scan; Route/Flight expose origin_airport;
GET /scans/{id}/flights accepts origin_airport filter; CreateScheduleRequest
and Schedule carry scan_mode; scheduler and run-now pass scan_mode through
- scan_processor: _write_route_incremental accepts origin_airport; process_scan
branches on scan_mode=reverse (country → airports × destinations × dates)
- Frontend: new CountrySelect component (populated from GET /api/v1/countries);
Scans page adds Direction toggle + CountrySelect for both modes; ScanDetails
shows Origin column for reverse scans and uses composite route keys; Re-run
preserves scan_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -301,6 +301,171 @@ def _migrate_add_pause_cancel_status(conn, verbose=True):
|
||||
print(" ✅ Migration complete: status now accepts 'paused' and 'cancelled'")
|
||||
|
||||
|
||||
def _migrate_add_reverse_scan_support(conn, verbose=True):
|
||||
"""
|
||||
Migration: Add reverse scan support across all affected tables.
|
||||
|
||||
Changes:
|
||||
- scans: relax origin CHECK (3→>=2), add scan_mode column
|
||||
- routes: add origin_airport column, replace unique index
|
||||
- flights: add origin_airport column
|
||||
- scheduled_scans: relax origin CHECK (3→>=2), add scan_mode column
|
||||
"""
|
||||
# ── scans table ──────────────────────────────────────────────────────────
|
||||
cursor = conn.execute("PRAGMA table_info(scans)")
|
||||
scans_cols = [row[1] for row in cursor.fetchall()]
|
||||
if scans_cols and 'scan_mode' not in scans_cols:
|
||||
if verbose:
|
||||
print(" 🔄 Migrating scans table: relaxing origin constraint, adding scan_mode…")
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
conn.execute("DROP TRIGGER IF EXISTS update_scans_timestamp")
|
||||
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_insert")
|
||||
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_update")
|
||||
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_delete")
|
||||
conn.execute("""
|
||||
CREATE TABLE scans_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
origin TEXT NOT NULL CHECK(length(origin) >= 2),
|
||||
country TEXT NOT NULL CHECK(length(country) >= 2),
|
||||
scan_mode TEXT NOT NULL DEFAULT 'forward'
|
||||
CHECK(scan_mode IN ('forward', 'reverse')),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
|
||||
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
|
||||
routes_scanned INTEGER NOT NULL DEFAULT 0 CHECK(routes_scanned >= 0),
|
||||
total_flights INTEGER NOT NULL DEFAULT 0 CHECK(total_flights >= 0),
|
||||
error_message TEXT,
|
||||
seat_class TEXT DEFAULT 'economy',
|
||||
adults INTEGER DEFAULT 1 CHECK(adults > 0 AND adults <= 9),
|
||||
scheduled_scan_id INTEGER,
|
||||
CHECK(end_date >= start_date),
|
||||
CHECK(routes_scanned <= total_routes OR total_routes = 0)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO scans_new (
|
||||
id, origin, country, scan_mode, start_date, end_date,
|
||||
created_at, updated_at, started_at, completed_at,
|
||||
status, total_routes, routes_scanned, total_flights,
|
||||
error_message, seat_class, adults, scheduled_scan_id
|
||||
)
|
||||
SELECT
|
||||
id, origin, country, 'forward', start_date, end_date,
|
||||
created_at, updated_at, started_at, completed_at,
|
||||
status, total_routes, routes_scanned, total_flights,
|
||||
error_message, seat_class, adults, scheduled_scan_id
|
||||
FROM scans
|
||||
""")
|
||||
conn.execute("DROP TABLE scans")
|
||||
conn.execute("ALTER TABLE scans_new RENAME TO scans")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.commit()
|
||||
if verbose:
|
||||
print(" ✅ scans table migrated")
|
||||
|
||||
# ── routes: add origin_airport column ────────────────────────────────────
|
||||
cursor = conn.execute("PRAGMA table_info(routes)")
|
||||
routes_cols = [row[1] for row in cursor.fetchall()]
|
||||
if routes_cols and 'origin_airport' not in routes_cols:
|
||||
if verbose:
|
||||
print(" 🔄 Migrating routes table: adding origin_airport column…")
|
||||
conn.execute("ALTER TABLE routes ADD COLUMN origin_airport TEXT")
|
||||
conn.commit()
|
||||
if verbose:
|
||||
print(" ✅ routes.origin_airport column added")
|
||||
|
||||
# ── routes: replace unique index ─────────────────────────────────────────
|
||||
cursor = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='uq_routes_scan_dest'"
|
||||
)
|
||||
if cursor.fetchone():
|
||||
if verbose:
|
||||
print(" 🔄 Replacing routes unique index…")
|
||||
conn.execute("DROP INDEX IF EXISTS uq_routes_scan_dest")
|
||||
conn.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_origin_dest
|
||||
ON routes(scan_id, COALESCE(origin_airport, ''), destination)
|
||||
""")
|
||||
conn.commit()
|
||||
if verbose:
|
||||
print(" ✅ Routes unique index replaced")
|
||||
|
||||
# ── flights: add origin_airport column ───────────────────────────────────
|
||||
cursor = conn.execute("PRAGMA table_info(flights)")
|
||||
flights_cols = [row[1] for row in cursor.fetchall()]
|
||||
if flights_cols and 'origin_airport' not in flights_cols:
|
||||
if verbose:
|
||||
print(" 🔄 Migrating flights table: adding origin_airport column…")
|
||||
conn.execute("ALTER TABLE flights ADD COLUMN origin_airport TEXT")
|
||||
conn.commit()
|
||||
if verbose:
|
||||
print(" ✅ flights.origin_airport column added")
|
||||
|
||||
# ── scheduled_scans: relax origin + add scan_mode ────────────────────────
|
||||
cursor = conn.execute("PRAGMA table_info(scheduled_scans)")
|
||||
sched_cols = [row[1] for row in cursor.fetchall()]
|
||||
if sched_cols and 'scan_mode' not in sched_cols:
|
||||
if verbose:
|
||||
print(" 🔄 Migrating scheduled_scans table: relaxing origin constraint, adding scan_mode…")
|
||||
conn.execute("DROP TRIGGER IF EXISTS update_scheduled_scans_timestamp")
|
||||
conn.execute("""
|
||||
CREATE TABLE scheduled_scans_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
origin TEXT NOT NULL CHECK(length(origin) >= 2),
|
||||
country TEXT NOT NULL CHECK(length(country) >= 2),
|
||||
scan_mode TEXT NOT NULL DEFAULT 'forward'
|
||||
CHECK(scan_mode IN ('forward', 'reverse')),
|
||||
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),
|
||||
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),
|
||||
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,
|
||||
CHECK(
|
||||
(frequency = 'weekly' AND day_of_week IS NOT NULL) OR
|
||||
(frequency = 'monthly' AND day_of_month IS NOT NULL) OR
|
||||
(frequency = 'daily')
|
||||
)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO scheduled_scans_new (
|
||||
id, origin, country, scan_mode, window_months, seat_class, adults,
|
||||
frequency, hour, minute, day_of_week, day_of_month,
|
||||
enabled, label, last_run_at, next_run_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, origin, country, 'forward', window_months, seat_class, adults,
|
||||
frequency, hour, minute, day_of_week, day_of_month,
|
||||
enabled, label, last_run_at, next_run_at, created_at, updated_at
|
||||
FROM scheduled_scans
|
||||
""")
|
||||
conn.execute("DROP TABLE scheduled_scans")
|
||||
conn.execute("ALTER TABLE scheduled_scans_new RENAME TO scheduled_scans")
|
||||
conn.commit()
|
||||
if verbose:
|
||||
print(" ✅ scheduled_scans table migrated")
|
||||
|
||||
|
||||
def initialize_database(db_path=None, verbose=True):
|
||||
"""
|
||||
Initialize or migrate the database.
|
||||
@@ -349,6 +514,7 @@ def initialize_database(db_path=None, verbose=True):
|
||||
_migrate_add_scheduled_scan_id_to_scans(conn, verbose)
|
||||
_migrate_add_timing_columns_to_scans(conn, verbose)
|
||||
_migrate_add_pause_cancel_status(conn, verbose)
|
||||
_migrate_add_reverse_scan_support(conn, verbose)
|
||||
|
||||
# Load and execute schema
|
||||
schema_sql = load_schema()
|
||||
|
||||
Reference in New Issue
Block a user