diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..3a390d4 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-python.python" + ] +} \ No newline at end of file diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index ff8ad19..7837e87 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -512,11 +512,13 @@ class ReadAction(AlpineBitsAction): start_date=start_date, hotel_code=hotelid ) ) - elif client_info.client_id: - # Remove reservations that have been acknowledged via client_id + elif client_info.username or client_info.client_id: + # Remove reservations that have been acknowledged via username (preferred) or client_id reservation_customer_pairs = ( await reservation_service.get_unacknowledged_reservations( - client_id=client_info.client_id, hotel_code=hotelid + username=client_info.username, + client_id=client_info.client_id, + hotel_code=hotelid ) ) else: @@ -611,9 +613,9 @@ class NotifReportReadAction(AlpineBitsAction): for entry in ( notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation ): # type: ignore - unique_id = entry.unique_id.id + md5_unique_id = entry.unique_id.id await reservation_service.record_acknowledgement( - client_id=client_info.client_id, unique_id=unique_id + client_id=client_info.client_id, unique_id=md5_unique_id, username=client_info.username ) return AlpineBitsResponse(response_xml, HttpStatusCode.OK) diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py index dfaa7ad..d75732a 100644 --- a/src/alpine_bits_python/db.py +++ b/src/alpine_bits_python/db.py @@ -329,6 +329,7 @@ class AckedRequest(Base): __tablename__ = "acked_requests" id = Column(Integer, primary_key=True) client_id = Column(String, index=True) + username = Column(String, index=True, nullable=True) # Username of the client making the request unique_id = Column( String, index=True ) # Should match Reservation.form_id or another unique field diff --git a/src/alpine_bits_python/migrations.py b/src/alpine_bits_python/migrations.py index 5b524b5..7c38ef0 100644 --- a/src/alpine_bits_python/migrations.py +++ b/src/alpine_bits_python/migrations.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine from .const import CONF_GOOGLE_ACCOUNT, CONF_HOTEL_ID, CONF_META_ACCOUNT from .logging_config import get_logger +from .db import Reservation _LOGGER = get_logger(__name__) @@ -223,6 +224,90 @@ async def _backfill_advertising_account_ids(engine: AsyncEngine, config: dict[st ) +async def migrate_add_username_to_acked_requests(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None: + """Migration: Add username column to acked_requests table and backfill with hotel usernames. + + This migration adds a username column to acked_requests to track acknowledgements by username + instead of just client_id. This improves consistency since client_ids can change but usernames are stable. + + For existing acknowledgements, this migration queries reservations to determine the hotel_code, + then looks up the corresponding username from the config and populates the new column. + + Safe to run multiple times - will skip if column already exists. + + Args: + engine: SQLAlchemy async engine + config: Application configuration dict containing hotel usernames + """ + _LOGGER.info("Running migration: add_username_to_acked_requests") + + # Add the username column if it doesn't exist + if await add_column_if_not_exists(engine, "acked_requests", "username", "VARCHAR"): + _LOGGER.info("Added username column to acked_requests table") + else: + _LOGGER.info("Username column already exists in acked_requests, skipping") + return + + # Backfill existing acknowledgements with username from config + if config: + await _backfill_acked_requests_username(engine, config) + else: + _LOGGER.warning("No config provided, skipping backfill of acked_requests usernames") + + +async def _backfill_acked_requests_username(engine: AsyncEngine, config: dict[str, Any]) -> None: + """Backfill username for existing acked_requests records. + + For each acknowledgement, find the corresponding reservation to determine its hotel_code, + then look up the username for that hotel in the config and update the acked_request record. + + Args: + engine: SQLAlchemy async engine + config: Application configuration dict + """ + _LOGGER.info("Backfilling usernames for existing acked_requests...") + + # Build a mapping of hotel_id -> username from config + hotel_usernames = {} + alpine_bits_auth = config.get("alpine_bits_auth", []) + + for hotel in alpine_bits_auth: + hotel_id = hotel.get(CONF_HOTEL_ID) + username = hotel.get("username") + + if hotel_id and username: + hotel_usernames[hotel_id] = username + + if not hotel_usernames: + _LOGGER.info("No hotel usernames found in config, skipping backfill") + return + + _LOGGER.info("Found %d hotel(s) with usernames in config", len(hotel_usernames)) + + # Update acked_requests with usernames by matching to reservations + total_updated = 0 + async with engine.begin() as conn: + for hotel_id, username in hotel_usernames.items(): + sql = text(""" + UPDATE acked_requests + SET username = :username + WHERE unique_id IN ( + SELECT unique_id FROM reservations WHERE hotel_code = :hotel_id + ) + AND username IS NULL + """) + result = await conn.execute( + sql, + {"username": username, "hotel_id": hotel_id} + ) + count = result.rowcount + if count > 0: + _LOGGER.info("Updated %d acknowledgements with username for hotel %s", count, hotel_id) + total_updated += count + + _LOGGER.info("Backfill complete: %d acknowledgements updated with username", total_updated) + + async def run_all_migrations(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None: """Run all pending migrations. @@ -239,6 +324,7 @@ async def run_all_migrations(engine: AsyncEngine, config: dict[str, Any] | None # Add new migrations here in chronological order await migrate_add_room_types(engine) await migrate_add_advertising_account_ids(engine, config) + await migrate_add_username_to_acked_requests(engine, config) _LOGGER.info("Database migrations completed successfully") diff --git a/src/alpine_bits_python/reservation_service.py b/src/alpine_bits_python/reservation_service.py index 4c4a020..6e83e14 100644 --- a/src/alpine_bits_python/reservation_service.py +++ b/src/alpine_bits_python/reservation_service.py @@ -172,28 +172,39 @@ class ReservationService: async def get_unacknowledged_reservations( self, - client_id: str, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, hotel_code: Optional[str] = None, + username: Optional[str] = None, + client_id: Optional[str] = None, ) -> list[tuple[Reservation, Customer]]: """Get reservations that haven't been acknowledged by a client. + Prioritizes checking by username if provided, falls back to client_id for backward compatibility. + Args: - client_id: The client ID to check acknowledgements for start_date: Filter by start date >= this value end_date: Filter by end date <= this value hotel_code: Filter by hotel code + username: The username of the client (preferred for lookup) + client_id: The client ID (fallback for backward compatibility) Returns: List of (Reservation, Customer) tuples that are unacknowledged """ - # Get all acknowledged md5_unique_ids for this client - acked_result = await self.session.execute( - select(AckedRequest.unique_id).where( - AckedRequest.client_id == client_id + # Get all acknowledged unique_ids for this client/username + if username: + acked_result = await self.session.execute( + select(AckedRequest.unique_id).where( + AckedRequest.username == username + ) + ) + else: + acked_result = await self.session.execute( + select(AckedRequest.unique_id).where( + AckedRequest.client_id == client_id + ) ) - ) acked_md5_ids = {row[0] for row in acked_result.all()} # Get all reservations with filters @@ -209,19 +220,21 @@ class ReservationService: ] async def record_acknowledgement( - self, client_id: str, unique_id: str + self, client_id: str, unique_id: str, username: Optional[str] = None ) -> AckedRequest: """Record that a client has acknowledged a reservation. Args: client_id: The client ID - unique_id: The unique_id of the reservation + unique_id: The unique_id of the reservation (md5_unique_id) + username: The username of the client making the request (optional) Returns: Created AckedRequest instance """ acked = AckedRequest( client_id=client_id, + username=username, unique_id=unique_id, timestamp=datetime.now(UTC), ) @@ -230,24 +243,37 @@ class ReservationService: await self.session.refresh(acked) return acked - async def is_acknowledged(self, client_id: str, unique_id: str) -> bool: + async def is_acknowledged(self, unique_id: str, username: Optional[str] = None, client_id: Optional[str] = None) -> bool: """Check if a reservation has been acknowledged by a client. + Prioritizes checking by username if provided, falls back to client_id for backward compatibility. + Args: - client_id: The client ID unique_id: The reservation unique_id + username: The username of the client (preferred for lookup) + client_id: The client ID (fallback for backward compatibility) Returns: True if acknowledged, False otherwise """ - result = await self.session.execute( - select(AckedRequest).where( - and_( - AckedRequest.client_id == client_id, - AckedRequest.unique_id == unique_id, + if username: + result = await self.session.execute( + select(AckedRequest).where( + and_( + AckedRequest.username == username, + AckedRequest.unique_id == unique_id, + ) + ) + ) + else: + result = await self.session.execute( + select(AckedRequest).where( + and_( + AckedRequest.client_id == client_id, + AckedRequest.unique_id == unique_id, + ) ) ) - ) return result.scalar_one_or_none() is not None @staticmethod diff --git a/tests/test_alpine_bits_server_read.py b/tests/test_alpine_bits_server_read.py index 6b36243..e2ba282 100644 --- a/tests/test_alpine_bits_server_read.py +++ b/tests/test_alpine_bits_server_read.py @@ -691,6 +691,7 @@ class TestAcknowledgments: acked_request = AckedRequest( unique_id=md5_hash, client_id=client_info.client_id, + username=client_info.username, timestamp=datetime.now(UTC), ) populated_db_session.add(acked_request) @@ -774,6 +775,7 @@ class TestAcknowledgments: acked_request = AckedRequest( unique_id=md5_hash, client_id=client_info.client_id, + username=client_info.username, timestamp=datetime.now(UTC), ) populated_db_session.add(acked_request)