Full-stack flight price scanner built on fast-flights v3 (SOCS cookie bypass): Backend (FastAPI + SQLite): - REST API with rate limiting, Pydantic v2 validation, paginated responses - Scan pipeline: resolves airports, queries every day in the window, saves individual flights + aggregate route stats to SQLite - Background async scan processor with real-time progress tracking - Airport search endpoint backed by OpenFlights dataset - Daily scan window (all dates, not monthly samples) Frontend (React 19 + TypeScript + Tailwind CSS v4): - Dashboard with live scan status and recent scans - Create scan form: country mode or specific airports (searchable dropdown) - Scan detail page with expandable route rows showing individual flights (date, airline, departure, arrival, price) loaded on demand - AirportSearch component with debounced live search and multi-select Database: - scans → routes → flights schema with FK cascade and auto-update triggers - Migrations for schema evolution (relaxed country constraint) Tests: - 74 tests: unit + integration, isolated per-test SQLite DB - Confirmed flight fixtures in tests/confirmed_flights.json (50 real flights, BDS→FMM Ryanair + BDS→DUS Eurowings, scraped Feb 2026) - Integration tests parametrized from confirmed routes Docker: - Multi-stage builds, Compose orchestration, Nginx reverse proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
11 KiB
Python
346 lines
11 KiB
Python
"""
|
||
Output formatting for flight comparison results.
|
||
|
||
Supports table, JSON, and CSV formats.
|
||
"""
|
||
|
||
import json
|
||
import csv
|
||
import sys
|
||
from typing import Optional
|
||
from datetime import datetime
|
||
|
||
try:
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
from rich import box
|
||
HAS_RICH = True
|
||
except ImportError:
|
||
HAS_RICH = False
|
||
|
||
|
||
def format_duration(minutes: int) -> str:
|
||
"""
|
||
Format duration in minutes to human-readable format.
|
||
|
||
Args:
|
||
minutes: Duration in minutes
|
||
|
||
Returns:
|
||
Formatted string like "9h 30m"
|
||
"""
|
||
if minutes == 0:
|
||
return "—"
|
||
|
||
hours = minutes // 60
|
||
mins = minutes % 60
|
||
|
||
if mins == 0:
|
||
return f"{hours}h"
|
||
|
||
return f"{hours}h {mins}m"
|
||
|
||
|
||
def format_table_single_date(
|
||
results: dict[str, list[dict]],
|
||
destination: str,
|
||
country: str,
|
||
date: str,
|
||
seat_class: str,
|
||
sort_by: str,
|
||
total_airports: int,
|
||
elapsed_time: float,
|
||
) -> None:
|
||
"""
|
||
Format and print results for single-date mode as a table.
|
||
|
||
Args:
|
||
results: Dict mapping airport IATA codes to lists of flights
|
||
destination: Destination airport code
|
||
country: Origin country code
|
||
date: Query date
|
||
seat_class: Cabin class
|
||
sort_by: Sort criterion (price or duration)
|
||
total_airports: Total number of airports scanned
|
||
elapsed_time: Total execution time in seconds
|
||
"""
|
||
if not HAS_RICH:
|
||
print("Rich library not installed, using plain text output")
|
||
_format_plain_single_date(results, destination, country, date, seat_class, sort_by, total_airports, elapsed_time)
|
||
return
|
||
|
||
console = Console()
|
||
|
||
# Print header
|
||
console.print()
|
||
console.print(
|
||
f"Flight Comparator: {country} → {destination} | {date} | "
|
||
f"{seat_class.title()} | Sorted by: {sort_by.title()}",
|
||
style="bold cyan"
|
||
)
|
||
console.print()
|
||
|
||
# Create table
|
||
table = Table(box=box.DOUBLE_EDGE, show_header=True, header_style="bold magenta")
|
||
|
||
table.add_column("#", justify="right", style="dim")
|
||
table.add_column("From", style="cyan")
|
||
table.add_column("Airline", style="green")
|
||
table.add_column("Depart", justify="center")
|
||
table.add_column("Arrive", justify="center")
|
||
table.add_column("Duration", justify="center")
|
||
table.add_column("Price", justify="right", style="yellow")
|
||
table.add_column("Market", justify="center")
|
||
|
||
# Flatten and sort results
|
||
all_flights = []
|
||
for airport_code, flights in results.items():
|
||
for flight in flights:
|
||
flight['airport_code'] = airport_code
|
||
all_flights.append(flight)
|
||
|
||
# Sort by price or duration
|
||
if sort_by == "duration":
|
||
all_flights.sort(key=lambda f: f.get('duration_minutes', 999999))
|
||
else: # price
|
||
all_flights.sort(key=lambda f: f.get('price', 999999))
|
||
|
||
# Add rows
|
||
airports_with_flights = set()
|
||
for idx, flight in enumerate(all_flights, 1):
|
||
airport_code = flight['airport_code']
|
||
airports_with_flights.add(airport_code)
|
||
|
||
from_text = f"{airport_code} {flight.get('city', '')}"
|
||
airline = flight.get('airline', 'Unknown')
|
||
depart = flight.get('departure_time', '—')
|
||
arrive = flight.get('arrival_time', '—')
|
||
duration = format_duration(flight.get('duration_minutes', 0))
|
||
price = f"{flight.get('currency', '€')}{flight.get('price', 0):.0f}"
|
||
|
||
# Simple market indicator (Low/Typical/High)
|
||
# In a real implementation, this would compare against price distribution
|
||
market = "Typical"
|
||
|
||
table.add_row(
|
||
str(idx),
|
||
from_text,
|
||
airline,
|
||
depart,
|
||
arrive,
|
||
duration,
|
||
price,
|
||
market
|
||
)
|
||
|
||
# Add rows for airports with no direct flights
|
||
no_direct_count = 0
|
||
for airport_code in results.keys():
|
||
if airport_code not in airports_with_flights:
|
||
from_text = f"{airport_code}"
|
||
table.add_row(
|
||
"—",
|
||
from_text,
|
||
"—",
|
||
"—",
|
||
"—",
|
||
"no direct flights found",
|
||
"—",
|
||
"—",
|
||
style="dim"
|
||
)
|
||
no_direct_count += 1
|
||
|
||
console.print(table)
|
||
console.print()
|
||
|
||
# Summary
|
||
console.print(
|
||
f"Scanned {total_airports} airports • "
|
||
f"{len(airports_with_flights)} with direct flights • "
|
||
f"Done in {elapsed_time:.1f}s",
|
||
style="dim"
|
||
)
|
||
console.print()
|
||
|
||
|
||
def format_table_seasonal(
|
||
results_by_month: dict[str, dict[str, list[dict]]],
|
||
new_connections: dict[str, str],
|
||
destination: str,
|
||
country: str,
|
||
seat_class: str,
|
||
total_airports: int,
|
||
elapsed_time: float,
|
||
) -> None:
|
||
"""
|
||
Format and print results for seasonal scan mode.
|
||
|
||
Args:
|
||
results_by_month: Dict mapping month strings to airport->flights dicts
|
||
new_connections: Dict mapping route keys to first appearance month
|
||
destination: Destination airport code
|
||
country: Origin country code
|
||
seat_class: Cabin class
|
||
total_airports: Total airports scanned
|
||
elapsed_time: Execution time in seconds
|
||
"""
|
||
if not HAS_RICH:
|
||
print("Rich library not installed, using plain text output")
|
||
_format_plain_seasonal(results_by_month, new_connections, destination, country, seat_class, total_airports, elapsed_time)
|
||
return
|
||
|
||
console = Console()
|
||
|
||
# Print header
|
||
console.print()
|
||
console.print(
|
||
f"Flight Comparator: {country} → {destination} | "
|
||
f"Seasonal scan: {len(results_by_month)} months | {seat_class.title()}",
|
||
style="bold cyan"
|
||
)
|
||
console.print()
|
||
|
||
# Process each month
|
||
for month in sorted(results_by_month.keys()):
|
||
month_results = results_by_month[month]
|
||
|
||
# Create month header
|
||
console.print(f"[bold yellow]{month.upper()}[/bold yellow]")
|
||
console.print()
|
||
|
||
# Flatten flights for this month
|
||
all_flights = []
|
||
for airport_code, flights in month_results.items():
|
||
for flight in flights:
|
||
flight['airport_code'] = airport_code
|
||
all_flights.append(flight)
|
||
|
||
# Sort by price
|
||
all_flights.sort(key=lambda f: f.get('price', 999999))
|
||
|
||
# Show top results
|
||
for idx, flight in enumerate(all_flights[:5], 1): # Top 5 per month
|
||
airport_code = flight['airport_code']
|
||
from_text = f"{airport_code} {flight.get('city', '')}"
|
||
airline = flight.get('airline', 'Unknown')
|
||
price = f"{flight.get('currency', '€')}{flight.get('price', 0):.0f}"
|
||
|
||
# Check if this is a new connection
|
||
route_key = f"{airport_code}->{destination}"
|
||
is_new = route_key in new_connections and new_connections[route_key] == month
|
||
|
||
market = "✨ NEW" if is_new else "Typical"
|
||
|
||
console.print(
|
||
f"{idx} │ {from_text:20} │ {airline:15} │ {price:8} │ {market}"
|
||
)
|
||
|
||
console.print()
|
||
|
||
console.print()
|
||
|
||
# Summary
|
||
if new_connections:
|
||
console.print("[bold]New connections detected:[/bold]")
|
||
for route, first_month in sorted(new_connections.items()):
|
||
console.print(f" • {route} (from {first_month})", style="green")
|
||
console.print()
|
||
|
||
console.print(
|
||
f"Scanned {len(results_by_month)} months × {total_airports} airports • "
|
||
f"Done in {elapsed_time:.1f}s",
|
||
style="dim"
|
||
)
|
||
console.print()
|
||
|
||
|
||
def _format_plain_single_date(results, destination, country, date, seat_class, sort_by, total_airports, elapsed_time):
|
||
"""Plain text fallback for single-date mode."""
|
||
print()
|
||
print(f"Flight Comparator: {country} → {destination} | {date} | {seat_class.title()} | Sorted by: {sort_by.title()}")
|
||
print("=" * 80)
|
||
print()
|
||
|
||
all_flights = []
|
||
for airport_code, flights in results.items():
|
||
for flight in flights:
|
||
flight['airport_code'] = airport_code
|
||
all_flights.append(flight)
|
||
|
||
if sort_by == "duration":
|
||
all_flights.sort(key=lambda f: f.get('duration_minutes', 999999))
|
||
else:
|
||
all_flights.sort(key=lambda f: f.get('price', 999999))
|
||
|
||
for idx, flight in enumerate(all_flights, 1):
|
||
print(f"{idx}. {flight['airport_code']} - {flight.get('airline', 'Unknown')} - "
|
||
f"{flight.get('currency', '€')}{flight.get('price', 0):.0f} - "
|
||
f"{format_duration(flight.get('duration_minutes', 0))}")
|
||
|
||
print()
|
||
print(f"Scanned {total_airports} airports • Done in {elapsed_time:.1f}s")
|
||
print()
|
||
|
||
|
||
def _format_plain_seasonal(results_by_month, new_connections, destination, country, seat_class, total_airports, elapsed_time):
|
||
"""Plain text fallback for seasonal mode."""
|
||
print()
|
||
print(f"Flight Comparator: {country} → {destination} | Seasonal scan: {len(results_by_month)} months | {seat_class.title()}")
|
||
print("=" * 80)
|
||
|
||
for month in sorted(results_by_month.keys()):
|
||
print(f"\n{month.upper()}")
|
||
print("-" * 40)
|
||
|
||
month_results = results_by_month[month]
|
||
all_flights = []
|
||
for airport_code, flights in month_results.items():
|
||
for flight in flights:
|
||
flight['airport_code'] = airport_code
|
||
all_flights.append(flight)
|
||
|
||
all_flights.sort(key=lambda f: f.get('price', 999999))
|
||
|
||
for idx, flight in enumerate(all_flights[:5], 1):
|
||
route_key = f"{flight['airport_code']}->{destination}"
|
||
is_new = route_key in new_connections and new_connections[route_key] == month
|
||
new_tag = " ✨ NEW" if is_new else ""
|
||
|
||
print(f"{idx}. {flight['airport_code']} - {flight.get('airline', 'Unknown')} - "
|
||
f"{flight.get('currency', '€')}{flight.get('price', 0):.0f}{new_tag}")
|
||
|
||
print()
|
||
if new_connections:
|
||
print("New connections detected:")
|
||
for route, first_month in sorted(new_connections.items()):
|
||
print(f" • {route} (from {first_month})")
|
||
|
||
print()
|
||
print(f"Scanned {len(results_by_month)} months × {total_airports} airports • Done in {elapsed_time:.1f}s")
|
||
print()
|
||
|
||
|
||
def format_json(results, **kwargs) -> None:
|
||
"""Format results as JSON."""
|
||
print(json.dumps(results, indent=2))
|
||
|
||
|
||
def format_csv(results: dict[str, list[dict]], **kwargs) -> None:
|
||
"""Format results as CSV."""
|
||
writer = csv.writer(sys.stdout)
|
||
writer.writerow(['Origin', 'Destination', 'Airline', 'Departure', 'Arrival', 'Duration_Min', 'Price', 'Currency'])
|
||
|
||
for airport_code, flights in results.items():
|
||
for flight in flights:
|
||
writer.writerow([
|
||
airport_code,
|
||
flight.get('destination', ''),
|
||
flight.get('airline', ''),
|
||
flight.get('departure_time', ''),
|
||
flight.get('arrival_time', ''),
|
||
flight.get('duration_minutes', 0),
|
||
flight.get('price', 0),
|
||
flight.get('currency', ''),
|
||
])
|