# FlightMonitor/data/storage.py import sqlite3 import os from datetime import ( datetime, timezone, timedelta, ) from typing import Optional, List, Dict, Any import time from flightmonitor.data.common_models import CanonicalFlightState from flightmonitor.utils.logger import get_logger from flightmonitor.data import config as app_config module_logger = get_logger(__name__) class DataStorage: """ Handles storage of flight data into daily SQLite database files. """ def __init__(self): self._current_db_path: Optional[str] = None self._connection: Optional[sqlite3.Connection] = None self._ensure_database_directory() module_logger.info("DataStorage initialized.") def _ensure_database_directory(self): try: abs_db_dir = os.path.abspath(app_config.DATABASE_DIRECTORY) if not os.path.exists(abs_db_dir): os.makedirs(abs_db_dir) module_logger.info(f"Database directory created: {abs_db_dir}") if not os.path.isdir(abs_db_dir): raise OSError(f"Path {abs_db_dir} exists but is not a directory.") if not os.access(abs_db_dir, os.W_OK): raise OSError(f"Directory {abs_db_dir} is not writable.") except OSError as e: module_logger.error( f"Database directory error for {app_config.DATABASE_DIRECTORY}: {e}", exc_info=True, ) raise def _get_db_path_for_date(self, target_date_utc: Optional[datetime] = None) -> str: if target_date_utc is None: target_date_utc = datetime.now(timezone.utc) db_filename = target_date_utc.strftime(app_config.DATABASE_FILENAME_FORMAT) db_path = os.path.join( os.path.abspath(app_config.DATABASE_DIRECTORY), db_filename ) return db_path def _get_db_connection( self, for_date_utc: Optional[datetime] = None ) -> Optional[sqlite3.Connection]: target_db_path = self._get_db_path_for_date(for_date_utc) if self._connection and self._current_db_path == target_db_path: return self._connection if self._connection: try: self._connection.close() module_logger.info(f"Closed DB connection to: {self._current_db_path}") except sqlite3.Error as e: module_logger.error( f"Error closing previous DB connection to {self._current_db_path}: {e}" ) self._connection = None self._current_db_path = target_db_path try: self._connection = sqlite3.connect(target_db_path, timeout=10.0) self._connection.execute("PRAGMA foreign_keys = ON;") self._connection.row_factory = sqlite3.Row module_logger.info(f"Opened DB connection to: {target_db_path}") self._init_tables_if_not_exist(self._connection) return self._connection except sqlite3.Error as e: module_logger.error( f"Failed to connect/initialize database {target_db_path}: {e}", exc_info=True, ) if self._connection: try: self._connection.close() except sqlite3.Error: pass self._connection = None self._current_db_path = None return None def _init_tables_if_not_exist(self, db_conn: sqlite3.Connection): cursor = None try: cursor = db_conn.cursor() cursor.execute( """ CREATE TABLE IF NOT EXISTS flights ( flight_id INTEGER PRIMARY KEY AUTOINCREMENT, icao24 TEXT NOT NULL UNIQUE, callsign TEXT, origin_country TEXT, first_seen_day REAL NOT NULL, last_seen_day REAL NOT NULL ) """ ) # MODIFICA: Aggiunto vincolo UNIQUE alla tabella positions cursor.execute( """ CREATE TABLE IF NOT EXISTS positions ( position_id INTEGER PRIMARY KEY AUTOINCREMENT, flight_id INTEGER NOT NULL, detection_timestamp REAL NOT NULL, latitude REAL NOT NULL, longitude REAL NOT NULL, baro_altitude_m REAL, geo_altitude_m REAL, velocity_mps REAL, true_track_deg REAL, vertical_rate_mps REAL, on_ground BOOLEAN NOT NULL, squawk TEXT, spi BOOLEAN, position_source TEXT, raw_data_provider TEXT, recorded_at REAL NOT NULL, FOREIGN KEY (flight_id) REFERENCES flights(flight_id) ON DELETE CASCADE, UNIQUE(flight_id, detection_timestamp) ) """ ) cursor.execute( "CREATE INDEX IF NOT EXISTS idx_positions_flight_id_timestamp ON positions (flight_id, detection_timestamp);" ) cursor.execute( "CREATE INDEX IF NOT EXISTS idx_flights_icao24 ON flights (icao24);" ) cursor.execute( "CREATE INDEX IF NOT EXISTS idx_positions_detection_timestamp ON positions (detection_timestamp);" ) db_conn.commit() module_logger.debug( f"Database tables and indexes ensured in {self._current_db_path}." ) except sqlite3.Error as e: module_logger.error( f"Error initializing tables in {self._current_db_path}: {e}", exc_info=True, ) if db_conn: db_conn.rollback() finally: if cursor: cursor.close() def add_or_update_flight_daily( self, icao24: str, callsign: Optional[str], origin_country: Optional[str], detection_timestamp: float, ) -> Optional[int]: if not icao24: module_logger.warning("icao24 is required to add or update a flight.") return None event_date_utc = datetime.fromtimestamp(detection_timestamp, timezone.utc) conn = self._get_db_connection(for_date_utc=event_date_utc) if not conn: module_logger.error( "Failed to get DB connection for add_or_update_flight_daily." ) return None cursor = None flight_id: Optional[int] = None try: cursor = conn.cursor() cursor.execute( "SELECT flight_id, callsign FROM flights WHERE icao24 = ?", (icao24,) ) row = cursor.fetchone() if row: flight_id = row["flight_id"] existing_callsign = row["callsign"] final_callsign = existing_callsign if callsign and callsign != existing_callsign: final_callsign = callsign elif not existing_callsign and callsign: final_callsign = callsign cursor.execute( """ UPDATE flights SET last_seen_day = ?, callsign = ? WHERE flight_id = ? """, (detection_timestamp, final_callsign, flight_id), ) module_logger.debug( f"Updated flight icao24={icao24}, flight_id={flight_id}, last_seen_day={detection_timestamp:.0f} in {self._current_db_path}" ) else: cursor.execute( """ INSERT INTO flights (icao24, callsign, origin_country, first_seen_day, last_seen_day) VALUES (?, ?, ?, ?, ?) """, ( icao24, callsign, origin_country, detection_timestamp, detection_timestamp, ), ) flight_id = cursor.lastrowid module_logger.info( f"Added new flight icao24={icao24}, flight_id={flight_id} to {self._current_db_path}" ) conn.commit() except sqlite3.Error as e: module_logger.error( f"DB error in add_or_update_flight_daily for icao24={icao24}: {e}", exc_info=True, ) if conn: conn.rollback() flight_id = None finally: if cursor: cursor.close() return flight_id def add_position_daily( self, flight_id: int, flight_state_obj: CanonicalFlightState ) -> Optional[int]: if not flight_id: module_logger.warning("flight_id is required to add a position.") return None if not isinstance(flight_state_obj, CanonicalFlightState): module_logger.error( f"Invalid type for flight_state_obj: expected CanonicalFlightState, got {type(flight_state_obj)}" ) return None event_date_utc = datetime.fromtimestamp( flight_state_obj.timestamp, timezone.utc ) conn = self._get_db_connection(for_date_utc=event_date_utc) if not conn: module_logger.error( f"Failed to get DB connection for add_position_daily (flight_id={flight_id})." ) return None cursor = None position_id: Optional[int] = None recorded_at_ts = time.time() if flight_state_obj.latitude is None or flight_state_obj.longitude is None: module_logger.warning( f"Skipping position for flight_id={flight_id} (ICAO: {flight_state_obj.icao24}) " f"due to missing latitude/longitude. Timestamp: {flight_state_obj.timestamp:.0f}" ) return None try: cursor = conn.cursor() # MODIFICA: Utilizza INSERT OR IGNORE per evitare duplicati sql = """ INSERT OR IGNORE INTO positions ( flight_id, detection_timestamp, latitude, longitude, baro_altitude_m, geo_altitude_m, velocity_mps, true_track_deg, vertical_rate_mps, on_ground, squawk, spi, position_source, raw_data_provider, recorded_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ params = ( flight_id, flight_state_obj.timestamp, flight_state_obj.latitude, flight_state_obj.longitude, flight_state_obj.baro_altitude_m, flight_state_obj.geo_altitude_m, flight_state_obj.velocity_mps, flight_state_obj.true_track_deg, flight_state_obj.vertical_rate_mps, flight_state_obj.on_ground, flight_state_obj.squawk, flight_state_obj.spi, ( str(flight_state_obj.position_source) if flight_state_obj.position_source is not None else None ), flight_state_obj.raw_data_provider, recorded_at_ts, ) cursor.execute(sql, params) # Se la riga è stata inserita (non ignorata), changes sarà 1 if cursor.rowcount > 0: position_id = cursor.lastrowid module_logger.debug( f"Added position for flight_id={flight_id} at {flight_state_obj.timestamp:.0f} (pos_id={position_id})" ) else: module_logger.debug( f"Ignored duplicate position for flight_id={flight_id} at {flight_state_obj.timestamp:.0f}" ) conn.commit() except sqlite3.Error as e: module_logger.error( f"DB error in add_position_daily for flight_id={flight_id} (ICAO: {flight_state_obj.icao24}): {e}", exc_info=True, ) if conn: conn.rollback() position_id = None finally: if cursor: cursor.close() return position_id def get_flight_track_for_icao_on_date( self, icao24_to_find: str, target_date_utc: Optional[datetime] = None, since_timestamp: Optional[float] = None, ) -> List[CanonicalFlightState]: if not icao24_to_find: return [] if target_date_utc is None: target_date_utc = datetime.now(timezone.utc) conn = self._get_db_connection(for_date_utc=target_date_utc) if not conn: return [] track_points: List[CanonicalFlightState] = [] cursor = None try: cursor = conn.cursor() cursor.execute( "SELECT flight_id FROM flights WHERE icao24 = ?", (icao24_to_find.lower(),), ) flight_row = cursor.fetchone() if not flight_row: return [] flight_id = flight_row["flight_id"] sql_query = "SELECT * FROM positions WHERE flight_id = ?" params = [flight_id] if since_timestamp is not None: sql_query += " AND detection_timestamp >= ?" params.append(since_timestamp) sql_query += " ORDER BY detection_timestamp ASC" cursor.execute(sql_query, params) position_rows = cursor.fetchall() if not position_rows: return [] for pos_row in position_rows: try: state = CanonicalFlightState( icao24=icao24_to_find, timestamp=pos_row["detection_timestamp"], last_contact_timestamp=pos_row["detection_timestamp"], latitude=pos_row["latitude"], longitude=pos_row["longitude"], on_ground=bool(pos_row["on_ground"]), callsign=None, origin_country=None, baro_altitude_m=pos_row["baro_altitude_m"], geo_altitude_m=pos_row["geo_altitude_m"], velocity_mps=pos_row["velocity_mps"], true_track_deg=pos_row["true_track_deg"], vertical_rate_mps=pos_row["vertical_rate_mps"], squawk=pos_row["squawk"], spi=( bool(pos_row["spi"]) if pos_row["spi"] is not None else None ), position_source=pos_row["position_source"], raw_data_provider=pos_row["raw_data_provider"], ) track_points.append(state) except Exception as e_state_create: module_logger.error( f"Error creating CanonicalFlightState from DB row for ICAO {icao24_to_find}: {e_state_create}", exc_info=True, ) continue except sqlite3.Error as e: module_logger.error( f"DB error retrieving track for {icao24_to_find} on {target_date_utc.strftime('%Y-%m-%d')}: {e}", exc_info=True, ) finally: if cursor: cursor.close() return track_points def close_connection(self): if self._connection: try: self._connection.close() module_logger.info( f"DB connection to {self._current_db_path} closed by request." ) except sqlite3.Error as e: module_logger.error( f"Error explicitly closing DB connection to {self._current_db_path}: {e}" ) finally: self._connection = None self._current_db_path = None