diff --git a/alpinebits.log b/alpinebits.log index 94a6c66..46220ea 100644 --- a/alpinebits.log +++ b/alpinebits.log @@ -14073,3 +14073,41 @@ IndexError: list index out of range 2025-10-15 08:52:56 - root - INFO - Logging to file: alpinebits.log 2025-10-15 08:52:56 - root - INFO - Logging configured at INFO level 2025-10-15 08:52:58 - alpine_bits_python.email_service - INFO - Email service initialized: smtp.titan.email:465 +2025-10-16 16:15:42 - root - INFO - Logging to file: alpinebits.log +2025-10-16 16:15:42 - root - INFO - Logging configured at INFO level +2025-10-16 16:15:42 - alpine_bits_python.email_monitoring - INFO - DailyReportScheduler initialized: send_time=08:00, recipients=[] +2025-10-16 16:15:42 - root - INFO - Daily report scheduler configured for Pushover (primary worker) +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Application startup initiated (primary_worker=True) +2025-10-16 16:15:42 - alpine_bits_python.alpinebits_server - INFO - Initializing action instance for AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT +2025-10-16 16:15:42 - alpine_bits_python.alpinebits_server - INFO - Initializing action instance for AlpineBitsActionName.OTA_PING +2025-10-16 16:15:42 - alpine_bits_python.alpinebits_server - INFO - Initializing action instance for AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS +2025-10-16 16:15:42 - alpine_bits_python.alpinebits_server - INFO - Initializing action instance for AlpineBitsActionName.OTA_READ +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Hotel 39054_001 has no push_endpoint configured +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Hotel 135 has no push_endpoint configured +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Hotel 39052_001 has no push_endpoint configured +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Hotel 39040_001 has no push_endpoint configured +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Starting database migrations... +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Running migration: add_room_types +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Adding column reservations.room_type_code (VARCHAR) +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Successfully added column reservations.room_type_code +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Adding column reservations.room_classification_code (VARCHAR) +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Successfully added column reservations.room_classification_code +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Adding column reservations.room_type (VARCHAR) +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Successfully added column reservations.room_type +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Migration add_room_types: Added 3 columns +2025-10-16 16:15:42 - alpine_bits_python.migrations - INFO - Database migrations completed successfully +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Database tables checked/created at startup. +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - All existing customers already have hashed data +2025-10-16 16:15:42 - alpine_bits_python.email_monitoring - INFO - ReservationStatsCollector initialized with 4 hotels +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Stats collector initialized and hooked up to report scheduler +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Sending test daily report on startup (last 24 hours) +2025-10-16 16:15:42 - alpine_bits_python.email_monitoring - INFO - Collecting reservation stats from 2025-10-15 16:15:42 to 2025-10-16 16:15:42 +2025-10-16 16:15:42 - alpine_bits_python.email_monitoring - INFO - Collected stats: 9 total reservations across 1 hotels +2025-10-16 16:15:42 - alpine_bits_python.email_service - WARNING - No recipients specified for email: AlpineBits Daily Report - 2025-10-16 +2025-10-16 16:15:42 - alpine_bits_python.api - ERROR - Failed to send test daily report via email on startup +2025-10-16 16:15:42 - alpine_bits_python.pushover_service - INFO - Pushover notification sent successfully: AlpineBits Daily Report - 2025-10-16 +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Test daily report sent via Pushover successfully on startup +2025-10-16 16:15:42 - alpine_bits_python.email_monitoring - INFO - Daily report scheduler started +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Daily report scheduler started +2025-10-16 16:15:42 - alpine_bits_python.api - INFO - Application startup complete +2025-10-16 16:15:42 - alpine_bits_python.email_monitoring - INFO - Next daily report scheduled for 2025-10-17 08:00:00 (in 15.7 hours) diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index f210d7d..ffdb6b7 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -43,6 +43,7 @@ from .db import Reservation as DBReservation from .email_monitoring import ReservationStatsCollector from .email_service import create_email_service from .logging_config import get_logger, setup_logging +from .migrations import run_all_migrations from .notification_adapters import EmailNotificationAdapter, PushoverNotificationAdapter from .notification_service import NotificationService from .pushover_service import create_pushover_service @@ -276,7 +277,13 @@ async def lifespan(app: FastAPI): elif hotel_id and not push_endpoint: _LOGGER.info("Hotel %s has no push_endpoint configured", hotel_id) - # Create tables + # Run database migrations first (only primary worker to avoid race conditions) + if is_primary: + await run_all_migrations(engine) + else: + _LOGGER.info("Skipping migrations (non-primary worker)") + + # Create tables (all workers) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) _LOGGER.info("Database tables checked/created at startup.") diff --git a/src/alpine_bits_python/migrations.py b/src/alpine_bits_python/migrations.py new file mode 100644 index 0000000..68a2363 --- /dev/null +++ b/src/alpine_bits_python/migrations.py @@ -0,0 +1,115 @@ +"""Database migrations for AlpineBits. + +This module contains migration functions that are automatically run at app startup +to update existing database schemas without losing data. +""" + +from sqlalchemy import inspect, text +from sqlalchemy.ext.asyncio import AsyncEngine + +from .logging_config import get_logger + +_LOGGER = get_logger(__name__) + + +async def check_column_exists(engine: AsyncEngine, table_name: str, column_name: str) -> bool: + """Check if a column exists in a table. + + Args: + engine: SQLAlchemy async engine + table_name: Name of the table to check + column_name: Name of the column to check + + Returns: + True if column exists, False otherwise + """ + async with engine.connect() as conn: + def _check(connection): + inspector = inspect(connection) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + result = await conn.run_sync(_check) + return result + + +async def add_column_if_not_exists( + engine: AsyncEngine, + table_name: str, + column_name: str, + column_type: str = "VARCHAR" +) -> bool: + """Add a column to a table if it doesn't already exist. + + Args: + engine: SQLAlchemy async engine + table_name: Name of the table + column_name: Name of the column to add + column_type: SQL type of the column (default: VARCHAR) + + Returns: + True if column was added, False if it already existed + """ + exists = await check_column_exists(engine, table_name, column_name) + + if exists: + _LOGGER.debug("Column %s.%s already exists, skipping", table_name, column_name) + return False + + _LOGGER.info("Adding column %s.%s (%s)", table_name, column_name, column_type) + + async with engine.begin() as conn: + sql = f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}" + await conn.execute(text(sql)) + + _LOGGER.info("Successfully added column %s.%s", table_name, column_name) + return True + + +async def migrate_add_room_types(engine: AsyncEngine) -> None: + """Migration: Add RoomTypes fields to reservations table. + + This migration adds three optional fields: + - room_type_code: String (max 8 chars) + - room_classification_code: String (numeric pattern) + - room_type: String (enum: 1-5) + + Safe to run multiple times - will skip if columns already exist. + """ + _LOGGER.info("Running migration: add_room_types") + + added_count = 0 + + # Add each column if it doesn't exist + if await add_column_if_not_exists(engine, "reservations", "room_type_code", "VARCHAR"): + added_count += 1 + + if await add_column_if_not_exists(engine, "reservations", "room_classification_code", "VARCHAR"): + added_count += 1 + + if await add_column_if_not_exists(engine, "reservations", "room_type", "VARCHAR"): + added_count += 1 + + if added_count > 0: + _LOGGER.info("Migration add_room_types: Added %d columns", added_count) + else: + _LOGGER.info("Migration add_room_types: No changes needed (already applied)") + + +async def run_all_migrations(engine: AsyncEngine) -> None: + """Run all pending migrations. + + This function should be called at app startup, before Base.metadata.create_all. + Each migration function should be idempotent (safe to run multiple times). + """ + _LOGGER.info("Starting database migrations...") + + try: + # Add new migrations here in chronological order + await migrate_add_room_types(engine) + + _LOGGER.info("Database migrations completed successfully") + + except Exception as e: + _LOGGER.exception("Migration failed: %s", e) + raise diff --git a/test_migration.db b/test_migration.db new file mode 100644 index 0000000..512a004 Binary files /dev/null and b/test_migration.db differ