diff --git a/alpinebits.log b/alpinebits.log index a17a9b0..7c7a14a 100644 --- a/alpinebits.log +++ b/alpinebits.log @@ -14059,3 +14059,87 @@ IndexError: list index out of range 2025-10-10 10:59:53 - alpine_bits_python.api - INFO - Hotel 39040_001 has no push_endpoint configured 2025-10-10 10:59:53 - alpine_bits_python.api - INFO - Database tables checked/created at startup. 2025-10-10 10:59:53 - httpx - INFO - HTTP Request: PUT http://testserver/api/hoteldata/conversions_import/test_reservation.xml "HTTP/1.1 401 Unauthorized" +2025-10-16 15:34:21 - root - INFO - Logging to file: alpinebits.log +2025-10-16 15:34:21 - root - INFO - Logging configured at INFO level +2025-10-16 15:34:21 - __main__ - INFO - ============================================================ +2025-10-16 15:34:21 - __main__ - INFO - Starting RoomTypes Migration +2025-10-16 15:34:21 - __main__ - INFO - ============================================================ +2025-10-16 15:34:21 - __main__ - INFO - Database URL: /alpinebits.db +2025-10-16 15:34:21 - __main__ - INFO - Checking which columns need to be added to reservations table... +2025-10-16 15:34:21 - __main__ - ERROR - Migration failed: 'Connection' object has no attribute 'sync_connection' +Traceback (most recent call last): + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 97, in main + await add_room_types_columns(engine) + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 53, in add_room_types_columns + columns_exist = await check_columns_exist(engine, table_name, columns_to_add) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 41, in check_columns_exist + result = await conn.run_sync(_check) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/ext/asyncio/engine.py", line 887, in run_sync + return await greenlet_spawn( + ^^^^^^^^^^^^^^^^^^^^^ + fn, self._proxied, *arg, _require_await=False, **kw + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 190, in greenlet_spawn + result = context.switch(*args, **kwargs) + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 37, in _check + inspector = inspect(connection.sync_connection) + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +AttributeError: 'Connection' object has no attribute 'sync_connection' +2025-10-16 15:34:36 - root - INFO - Logging to file: alpinebits.log +2025-10-16 15:34:36 - root - INFO - Logging configured at INFO level +2025-10-16 15:34:36 - __main__ - INFO - ============================================================ +2025-10-16 15:34:36 - __main__ - INFO - Starting RoomTypes Migration +2025-10-16 15:34:36 - __main__ - INFO - ============================================================ +2025-10-16 15:34:36 - __main__ - INFO - Database URL: /alpinebits.db +2025-10-16 15:34:36 - __main__ - INFO - Checking which columns need to be added to reservations table... +2025-10-16 15:34:36 - __main__ - ERROR - Migration failed: reservations +Traceback (most recent call last): + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 97, in main + await add_room_types_columns(engine) + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 53, in add_room_types_columns + columns_exist = await check_columns_exist(engine, table_name, columns_to_add) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 41, in check_columns_exist + result = await conn.run_sync(_check) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/ext/asyncio/engine.py", line 887, in run_sync + return await greenlet_spawn( + ^^^^^^^^^^^^^^^^^^^^^ + fn, self._proxied, *arg, _require_await=False, **kw + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 203, in greenlet_spawn + result = context.switch(value) + File "/home/divusjulius/repos/alpinebits_python/src/alpine_bits_python/util/migrate_add_room_types.py", line 38, in _check + existing_cols = [col['name'] for col in inspector.get_columns(table_name)] + ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^ + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/engine/reflection.py", line 869, in get_columns + col_defs = self.dialect.get_columns( + conn, table_name, schema, info_cache=self.info_cache, **kw + ) + File "", line 2, in get_columns + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/engine/reflection.py", line 106, in cache + ret = fn(self, con, *args, **kw) + File "/home/divusjulius/repos/alpinebits_python/.venv/lib/python3.13/site-packages/sqlalchemy/dialects/sqlite/base.py", line 2412, in get_columns + raise exc.NoSuchTableError( + f"{schema}.{table_name}" if schema else table_name + ) +sqlalchemy.exc.NoSuchTableError: reservations +2025-10-16 15:34:52 - root - INFO - Logging to file: alpinebits.log +2025-10-16 15:34:52 - root - INFO - Logging configured at INFO level +2025-10-16 15:34:52 - __main__ - INFO - ============================================================ +2025-10-16 15:34:52 - __main__ - INFO - Starting RoomTypes Migration +2025-10-16 15:34:52 - __main__ - INFO - ============================================================ +2025-10-16 15:34:52 - __main__ - INFO - Database URL: /alpinebits.db +2025-10-16 15:34:52 - __main__ - INFO - Ensuring database tables exist... +2025-10-16 15:34:52 - __main__ - INFO - Database tables checked/created. +2025-10-16 15:34:52 - __main__ - INFO - Checking which columns need to be added to reservations table... +2025-10-16 15:34:52 - __main__ - INFO - All RoomTypes columns already exist in reservations table. No migration needed. +2025-10-16 15:34:52 - __main__ - INFO - ============================================================ +2025-10-16 15:34:52 - __main__ - INFO - Migration completed successfully! +2025-10-16 15:34:52 - __main__ - INFO - ============================================================ diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index 0a15b04..cc32d6f 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -25,6 +25,7 @@ from .generated.alpinebits import ( OtaHotelResNotifRq, OtaResRetrieveRs, ProfileProfileType, + RoomTypeRoomType, UniqueIdType2, ) @@ -76,6 +77,13 @@ RetrieveRoomStays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays NotifHotelReservation = OtaHotelResNotifRq.HotelReservations.HotelReservation RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation +NotifRoomTypes = ( + OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.RoomTypes +) +RetrieveRoomTypes = ( + OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.RoomTypes +) + from .const import RESERVATION_ID_TYPE @@ -697,9 +705,29 @@ def _process_single_reservation( start=reservation.start_date.isoformat() if reservation.start_date else None, end=reservation.end_date.isoformat() if reservation.end_date else None, ) + + # RoomTypes (optional) - only create if at least one field is present + room_types = None + if any([reservation.room_type_code, reservation.room_classification_code, reservation.room_type]): + # Convert room_type string to enum if present + room_type_enum = None + if reservation.room_type: + room_type_enum = RoomTypeRoomType(reservation.room_type) + + # Create RoomType instance + room_type_obj = RoomStays.RoomStay.RoomTypes.RoomType( + room_type_code=reservation.room_type_code, + room_classification_code=reservation.room_classification_code, + room_type=room_type_enum, + ) + + # Create RoomTypes container + room_types = RoomStays.RoomStay.RoomTypes(room_type=room_type_obj) + room_stay = RoomStays.RoomStay( time_span=time_span, guest_counts=guest_counts, + room_types=room_types, ) room_stays = RoomStays( room_stay=[room_stay], diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py index 0bc9a8e..3c76944 100644 --- a/src/alpine_bits_python/db.py +++ b/src/alpine_bits_python/db.py @@ -127,6 +127,10 @@ class Reservation(Base): # Add hotel_code and hotel_name for XML hotel_code = Column(String) hotel_name = Column(String) + # RoomTypes fields (optional) + room_type_code = Column(String) + room_classification_code = Column(String) + room_type = Column(String) customer = relationship("Customer", back_populates="reservations") diff --git a/src/alpine_bits_python/schemas.py b/src/alpine_bits_python/schemas.py index 4affec1..0ee730c 100644 --- a/src/alpine_bits_python/schemas.py +++ b/src/alpine_bits_python/schemas.py @@ -58,6 +58,10 @@ class ReservationData(BaseModel): utm_campaign: str | None = Field(None, max_length=150) utm_term: str | None = Field(None, max_length=150) utm_content: str | None = Field(None, max_length=150) + # RoomTypes fields (optional) + room_type_code: str | None = Field(None, min_length=1, max_length=8) + room_classification_code: str | None = Field(None, pattern=r"[0-9]+") + room_type: str | None = Field(None, pattern=r"^[1-5]$") @model_validator(mode="after") def ensure_md5(self) -> "ReservationData": diff --git a/src/alpine_bits_python/util/migrate_add_room_types.py b/src/alpine_bits_python/util/migrate_add_room_types.py new file mode 100644 index 0000000..e9de879 --- /dev/null +++ b/src/alpine_bits_python/util/migrate_add_room_types.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Migration script to add RoomTypes fields to Reservation table. + +This migration adds three optional fields to the reservations table: +- room_type_code: String (max 8 chars) +- room_classification_code: String (numeric pattern) +- room_type: String (enum: 1-5) + +This script can be run manually before starting the server, or the changes +will be applied automatically when the server starts via Base.metadata.create_all. +""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path so we can import alpine_bits_python +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from sqlalchemy import inspect, text +from sqlalchemy.ext.asyncio import create_async_engine + +from alpine_bits_python.config_loader import load_config +from alpine_bits_python.db import get_database_url +from alpine_bits_python.logging_config import get_logger, setup_logging + +_LOGGER = get_logger(__name__) + + +async def check_columns_exist(engine, table_name: str, columns: list[str]) -> dict[str, bool]: + """Check which columns exist in the table. + + Returns a dict mapping column name to whether it exists. + """ + async with engine.connect() as conn: + def _check(connection): + inspector = inspect(connection) + existing_cols = [col['name'] for col in inspector.get_columns(table_name)] + return {col: col in existing_cols for col in columns} + + result = await conn.run_sync(_check) + return result + + +async def add_room_types_columns(engine): + """Add RoomTypes columns to reservations table if they don't exist.""" + from alpine_bits_python.db import Base + + table_name = "reservations" + columns_to_add = ["room_type_code", "room_classification_code", "room_type"] + + # First, ensure the table exists by creating all tables if needed + _LOGGER.info("Ensuring database tables exist...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + _LOGGER.info("Database tables checked/created.") + + _LOGGER.info("Checking which columns need to be added to %s table...", table_name) + + # Check which columns already exist + columns_exist = await check_columns_exist(engine, table_name, columns_to_add) + + columns_to_create = [col for col, exists in columns_exist.items() if not exists] + + if not columns_to_create: + _LOGGER.info("All RoomTypes columns already exist in %s table. No migration needed.", table_name) + return + + _LOGGER.info("Adding columns to %s table: %s", table_name, ", ".join(columns_to_create)) + + # Build ALTER TABLE statements for missing columns + # Note: SQLite supports ALTER TABLE ADD COLUMN but not ADD MULTIPLE COLUMNS + async with engine.begin() as conn: + for column in columns_to_create: + sql = f"ALTER TABLE {table_name} ADD COLUMN {column} VARCHAR" + _LOGGER.info("Executing: %s", sql) + await conn.execute(text(sql)) + + _LOGGER.info("Successfully added %d columns to %s table", len(columns_to_create), table_name) + + +async def main(): + """Run the migration.""" + try: + # Load config + config = load_config() + setup_logging(config) + except Exception as e: + _LOGGER.warning("Failed to load config: %s. Using defaults.", e) + config = {} + + _LOGGER.info("=" * 60) + _LOGGER.info("Starting RoomTypes Migration") + _LOGGER.info("=" * 60) + + # Get database URL + database_url = get_database_url(config) + _LOGGER.info("Database URL: %s", database_url.replace("://", "://***:***@").split("@")[-1]) + + # Create engine + engine = create_async_engine(database_url, echo=False) + + try: + # Run migration + await add_room_types_columns(engine) + + _LOGGER.info("=" * 60) + _LOGGER.info("Migration completed successfully!") + _LOGGER.info("=" * 60) + + except Exception as e: + _LOGGER.exception("Migration failed: %s", e) + sys.exit(1) + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main())