Introduced Database Compatibility checks for old or new versions with different schemas

This commit is contained in:
SimplifiedPrivacy 2026-06-28 21:49:19 -04:00
parent 2aa077a400
commit 69834d85d5
8 changed files with 466 additions and 59 deletions

View file

@ -6,6 +6,8 @@ import os
@dataclass(frozen=True) @dataclass(frozen=True)
class Constants: class Constants:
DB_VERSION_THIS_APP_WANTS = 1
# ticketing group: # ticketing group:
TICKET_API_BASE_URL: Final[str] = os.environ.get( TICKET_API_BASE_URL: Final[str] = os.environ.get(
"TICKET_API_BASE_URL", "https://ticket.hydraveil.net" "TICKET_API_BASE_URL", "https://ticket.hydraveil.net"

View file

@ -30,9 +30,7 @@ class ProfileController:
@staticmethod @staticmethod
def create(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None): def create(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None):
print("inside profile controller about to save")
profile.save() profile.save()
print("inside profile controller finished save")
if profile_observer is not None: if profile_observer is not None:
profile_observer.notify('created', profile) profile_observer.notify('created', profile)

View file

@ -65,26 +65,15 @@ class BaseProfile(ABC):
return type(self).__name__ == 'SystemProfile' return type(self).__name__ == 'SystemProfile'
def save(self: Self): def save(self: Self):
print("save has been called")
config_dict = self.to_dict() # Get dict, not JSON string config_dict = self.to_dict() # Get dict, not JSON string
print(f"got past the to_dict, config_dict: {config_dict}") location_dict = self.location.to_dict() # this is from SQLAlchemy, and not JSON-models, that's why it's separate.
print("making a location dict")
location_dict = self.location.to_dict()
print(f"got a location dict {location_dict}")
if self.location: if self.location:
print("if self.location is true..")
config_dict["location"] = location_dict config_dict["location"] = location_dict
print(f"dumping into config now {config_dict}")
config_file_contents = json.dumps(config_dict, indent=4) + '\n' config_file_contents = json.dumps(config_dict, indent=4) + '\n'
print(f"config_file_contents: {config_file_contents}") # legacy version:
# legacy:
# config_file_contents = f'{self.to_json(indent=4)}\n' # config_file_contents = f'{self.to_json(indent=4)}\n'
os.makedirs(self.get_config_path(), exist_ok=True) os.makedirs(self.get_config_path(), exist_ok=True)

View file

@ -4,7 +4,7 @@ from sqlalchemy import create_engine
from typing import Type, Dict, Any from typing import Type, Dict, Any
from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from core.models.manage.session_management import get_session from core.models.manage.session_management import get_session
from core.models.orm_models.Base import Base from core.models.orm_models.Base import BaseModel
# all models it knows how to do: # all models it knows how to do:
from core.models.orm_models.CachedSync import CachedSync from core.models.orm_models.CachedSync import CachedSync

View file

@ -1,61 +1,120 @@
from sqlalchemy.orm import sessionmaker from core.errors.logger import logger
from sqlalchemy import create_engine, inspect
from core.models.orm_models.Base import BaseModel from core.models.orm_models.Base import BaseModel
# from core.Constants import Constants from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import os import os
from pathlib import Path from pathlib import Path
"""
Note:
At the bottom it initializes the Session and sets it up outside the function loose.
"""
# ============================================================================
# PATH & INITIALIZATION
# ============================================================================
def get_path(): def get_path():
# XDG Base Directory Specification """Returns XDG Base Directory path or ~/.local/share/hydra-veil"""
xdg_data_home = os.environ.get("XDG_DATA_HOME") xdg_data = os.getenv("XDG_DATA_HOME")
if xdg_data_home: if xdg_data:
return Path(xdg_data_home) / "my-app" return Path(xdg_data) / "my-app"
return Path.home() / ".local" / "share" / "hydra-veil" return Path.home() / ".local" / "share" / "hydra-veil"
# ============================================================================
# GLOBAL STATE
# ============================================================================
system_path = get_path() system_path = get_path()
database_path = f"{system_path}/storage.db" database_path = system_path / "storage.db"
engine = None
# Ensure the database directory exists Session = None
db_dir = Path(database_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Create the engine
engine = create_engine(f"sqlite:///{database_path}")
# Create all tables
BaseModel.metadata.create_all(engine)
# Create session
Session = sessionmaker(bind=engine)
_session = None _session = None
def init_session(): # ============================================================================
""" # ENGINE & SESSION MANAGEMENT
Called once at application startup using global variables for the session, # ============================================================================
which initialize prior to being called with a false flag.
The reason for this is so the app can load prior to be called, def _reinitialize_engine_and_session():
while keeping it a globally accessible variable
""" """
Recreate the global engine and Session factory.
Call this after deleting the database to sync globals with the new DB file.
"""
global engine, Session
system_path.mkdir(parents=True, exist_ok=True)
engine = create_engine(f"sqlite:///{database_path}")
Session = sessionmaker(bind=engine)
def init_session():
"""Initialize the global _session from the global Session factory."""
global _session global _session
BaseModel.metadata.create_all(engine) if Session is None:
raise RuntimeError("Session factory not initialized. Call _reinitialize_engine_and_session() first.")
_session = Session() _session = Session()
def get_session(): def get_session():
"""Get the persistent session""" """Return the global _session or raise RuntimeError."""
if _session is None: if _session is None:
raise RuntimeError("Session not initialized. Call init_session() first.") raise RuntimeError("Session not initialized. Call init_session() first.")
return _session return _session
def close_session():
"""
Used prior to shutting down the application to break SQL connections.
In the current refactor, it may be used prior to shut down, such as sync completion, def close_session():
because a low amount of the total models currently use the new system. """Close and reset the global _session."""
"""
global _session global _session
if _session: if _session is not None:
_session.close() _session.close()
_session = None _session = None
# ============================================================================
# DATABASE OPERATIONS
# ============================================================================
def create_ONLY_db_version_table():
"""Create only the database_version table using the global engine."""
if engine is None:
raise RuntimeError("Engine not initialized. Call _reinitialize_engine_and_session() first.")
from core.models.orm_models.DatabaseVersion import database_version
database_version.create(engine, checkfirst=True)
# DatabaseVersion.__table__.create(engine, checkfirst=True)
def create_ALL_tables():
"""Create all tables from BaseModel.metadata using the global engine."""
print("starting create_ALL_tables")
if engine is None:
raise RuntimeError("Engine not initialized. Call _reinitialize_engine_and_session() first.")
print("passed engine test")
from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator
from core.models.orm_models.CachedSync import CachedSync
from core.models.orm_models.EncryptedProxy import EncryptedProxy
from core.models.SubscriptionPlan import SubscriptionPlan
from core.models.session.ApplicationVersion import ApplicationVersion
try:
print("trying this")
BaseModel.metadata.create_all(engine, checkfirst=True)
print("Returning True for Creating all Tables..")
return True
except:
print("couldn't make all tables")
return False
# ============================================================================
# STARTUP: Initialize engine and session on import
# ============================================================================
_reinitialize_engine_and_session()
logger.info("[DB MANAGEMENT] Started Engine in Session Management Module..")
init_session()
logger.info("[DB MANAGEMENT] Initialized Session")

View file

@ -0,0 +1,348 @@
from core.models.orm_models.DatabaseVersion import database_version
from core.Constants import Constants
from core.errors.logger import logger
# generic
from sqlalchemy import exc, text
from sqlalchemy.orm import Session
from typing import Optional
def get_database_version(session: Session) -> Optional[int]:
"""
Purpose:
Retrieve the current database version from the single-row version table.
Rank:
Helper
Called By:
check_database_compatibility
Features:
Handles errors with multiple items, failed loads, and other conditions.
Returns:
int: The database version, or None if retrieval fails.
"""
func_name = "get_database_version"
# Validate session
if session is None:
logger.error(f"{func_name}: Session is None")
return None
if not isinstance(session, Session):
logger.error(f"{func_name}: Invalid session type: {type(session)}")
return None
try:
# Attempt to query the table
logger.debug(f"{func_name}: Attempting to query database_version table")
result = session.query(database_version).all()
# Handle empty table
if not result:
logger.warning(f"{func_name}: Table is empty, no version row found")
return None
# Handle multiple rows (shouldn't happen, but log it)
if len(result) > 1:
logger.warning(
f"{func_name}: Expected 1 row, found {len(result)}. "
"Table should contain only a single row. Returning first version."
)
# Extract version from first row
version_row = result[0]
version = version_row.version
# Type validation
if not isinstance(version, int):
logger.error(
f"{func_name}: Version column type mismatch. "
f"Expected int, got {type(version).__name__}: {version}"
)
return None
if version < 0:
logger.warning(
f"{func_name}: Version is negative ({version}). "
"This may indicate corrupted data."
)
logger.info(f"{func_name}: Successfully retrieved version: {version}")
return version
except exc.NoSuchTableError:
logger.error(
f"{func_name}: Table 'database_version' does not exist. "
"Ensure the table is created with `database_version.create(engine, checkfirst=True)`"
)
return None
except exc.OperationalError as e:
logger.error(
f"{func_name}: Database operational error (connection, locked, or permissions). "
f"Details: {str(e)}"
)
return None
except exc.DatabaseError as e:
logger.error(
f"{func_name}: General database error. "
f"Details: {str(e)}"
)
return None
except exc.StatementError as e:
logger.error(
f"{func_name}: SQL statement error. "
f"Details: {str(e)}"
)
return None
except AttributeError as e:
logger.error(
f"{func_name}: Row does not have 'version' attribute. "
f"Verify table schema matches definition. Details: {str(e)}"
)
return None
except TypeError as e:
logger.error(
f"{func_name}: Type error when accessing version. "
f"Details: {str(e)}"
)
return None
except Exception as e:
logger.critical(
f"{func_name}: Unexpected exception type {type(e).__name__}. "
f"Details: {str(e)}",
exc_info=True
)
return None
def insert_new_version(session: Session, new_version: int) -> bool:
"""
Purpose:
Insert (or replace) the database version. Clears existing rows and inserts new version.
Rank:
Helper
Called By:
GUI's startup script in __main__
Args:
session: SQLAlchemy session
new_version: The version number to insert (must be a positive integer)
Returns:
bool: True if successful, False otherwise.
does NOT raise errors.
"""
func_name = "insert_new_version" # for logging purposes
# Validate session
if session is None:
logger.error(f"{func_name}: Session is None")
return False
if not isinstance(session, Session):
logger.error(f"{func_name}: Invalid session type: {type(session)}")
return False
# Validate new_version parameter
if new_version is None:
logger.error(f"{func_name}: new_version is None")
return False
if not isinstance(new_version, int):
logger.error(
f"{func_name}: new_version must be an integer, got {type(new_version).__name__}: {new_version}"
)
return False
if new_version < 0:
logger.error(
f"{func_name}: new_version must be non-negative, got {new_version}"
)
return False
try:
logger.debug(f"{func_name}: Starting transaction for version insert/replace")
# Check if table exists before proceeding
try:
session.query(database_version).limit(1).all()
except exc.NoSuchTableError:
logger.error(
f"{func_name}: Table 'database_version' does not exist. "
"Ensure the table is created with `database_version.create(engine, checkfirst=True)`"
)
return False
# Delete existing rows
logger.debug(f"{func_name}: Deleting existing version rows")
deleted_count = session.query(database_version).delete()
logger.debug(f"{func_name}: Deleted {deleted_count} existing row(s)")
if deleted_count > 1:
logger.warning(
f"{func_name}: Deleted {deleted_count} rows. "
"Table should contain only a single row at any time."
)
# Actually Insert the new version
logger.debug(f"{func_name}: Inserting new version {new_version}")
insert_stmt = database_version.insert().values(version=new_version)
session.execute(insert_stmt)
# Commit transaction
session.commit()
logger.info(f"{func_name}: Successfully inserted version {new_version}")
return True
except exc.NoSuchTableError:
logger.error(
f"{func_name}: Table 'database_version' does not exist. "
"Ensure the table is created with `database_version.create(engine, checkfirst=True)`"
)
session.rollback()
return False
except exc.IntegrityError as e:
logger.error(
f"{func_name}: Integrity constraint violation (e.g., primary key conflict). "
f"Details: {str(e)}"
)
session.rollback()
return False
except exc.OperationalError as e:
logger.error(
f"{func_name}: Database operational error (connection, locked, or permissions). "
f"Details: {str(e)}"
)
session.rollback()
return False
except exc.DatabaseError as e:
logger.error(
f"{func_name}: General database error. "
f"Details: {str(e)}"
)
session.rollback()
return False
except exc.StatementError as e:
logger.error(
f"{func_name}: SQL statement error. "
f"Details: {str(e)}"
)
session.rollback()
return False
except ValueError as e:
logger.error(
f"{func_name}: Value error during insert. "
f"Details: {str(e)}"
)
session.rollback()
return False
except Exception as e:
logger.critical(
f"{func_name}: Unexpected exception type {type(e).__name__}. "
f"Details: {str(e)}",
exc_info=True
)
session.rollback()
return False
"""Spits back a string based on the reason and error."""
def get_custom_message(reason: str, compatability_dict: dict) -> str:
"""
Called By:
GUI's startup script in __main__
"""
if reason == "upgrade":
custom_error = "You just upgraded HydraVeil, so let's upgrade your database to handle the new data types."
elif reason == "old_app":
custom_error = "You're using an old version of HydraVeil, with a newer version of the database. This means the older app would crash trying to handle the newer data."
else:
error_msg = compatability_dict.get("error", "Unknown reason.")
custom_error = f"There was an error with upgrading your database version. Please tell customer support: {error_msg}."
return custom_error
# ============================================================================
# ORCHESTRATOR
# ============================================================================
def check_database_compatibility(session: Session) -> dict:
"""
Purpose:
Evaluates if the user's existing database is incompatible with the app's version.
Rank:
Orchestrator
Called By:
GUI's startup script in __main__
Returns:
Dictionary of True/False, with reason
NO error raises.
"""
db_version_you_have = get_database_version(session)
if db_version_you_have is None:
print("Nothing there")
# First run, corrupted, or empty
return {
"result": True,
"reason": "no_database" # Allow initialization still, since there's nothing to delete.
}
logger.info(f"[DB MANAGEMENT] The Database version this APP WANTS is {Constants.DB_VERSION_THIS_APP_WANTS}")
logger.info(f"[DB MANAGEMENT] The Database version this device currently has is {db_version_you_have}")
if Constants.DB_VERSION_THIS_APP_WANTS == db_version_you_have:
logger.info(f"[DB MANAGEMENT] Great, the database versions match exactly.")
return {
"result": True,
"reason": None,
}
# BAD!! Old version,
elif Constants.DB_VERSION_THIS_APP_WANTS < db_version_you_have:
logger.info(f"[DB MANAGEMENT] The App version is older, this is NOT compatabile")
# make them wipe the tables, or switch versions of the app
return {
"result": False,
"reason": "old_app",
"old_db_version": db_version_you_have
}
# Upgrade User:
elif Constants.DB_VERSION_THIS_APP_WANTS > db_version_you_have:
logger.info(f"[DB MANAGEMENT] The user has an upgraded App, and an older database")
return {
"result": False,
"reason": "upgrade",
"old_db_version": db_version_you_have
}
# this is an error
else:
logger.error(f"[DB MANAGEMENT] Unknown Error inside version check module with the King Orchestration")
return {
"result": False,
"reason": "error",
"old_db_version": db_version_you_have,
"error": "unknown result"
}

View file

@ -0,0 +1,11 @@
from sqlalchemy import Column, Integer, MetaData, Table
# Separate metadata for the compatibility table only
compat_metadata = MetaData()
database_version = Table(
'database_version',
compat_metadata,
Column('version', Integer, primary_key=True)
)

View file

@ -14,7 +14,7 @@ def get_data_from_api(
client_observer: Optional[ClientObserver] = None, client_observer: Optional[ClientObserver] = None,
connection_observer: Optional[ConnectionObserver] = None connection_observer: Optional[ConnectionObserver] = None
) -> dict: ) -> dict:
logger.info("Syncing with the API...") logger.info("Syncing with the API in get_data_from_api...")
# Get and validate the base URL # Get and validate the base URL
rejected_list = [None, False, ""] rejected_list = [None, False, ""]
@ -44,8 +44,8 @@ def get_data_from_api(
if final_result: if final_result:
logger.info("Successfully retrieved sync versions metadata from API") logger.info("Successfully retrieved sync versions metadata from API")
return {"success": True, "data": final_result, "error": None} return {"success": True, "data": final_result, "error": None}
else:
error_msg = "API response missing 'data' field" error_msg = f"API response missing 'data' field at the end of get_data_from_api. This is the response from the other module: {sync_results}"
logger.error(error_msg) logger.error(error_msg)
return {"success": False, "data": None, "error": error_msg} return {"success": False, "data": None, "error": error_msg}