SXXXXXXX_FlightMonitor/flightmonitor/data/storage.py
2025-06-13 11:48:49 +02:00

432 lines
16 KiB
Python

# 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