"""Action handler for OTA_HotelInvCountNotif:FreeRooms.""" from __future__ import annotations from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from typing import Any from sqlalchemy import delete, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.ext.asyncio import AsyncSession from xsdata.formats.dataclass.serializers.config import SerializerConfig from xsdata_pydantic.bindings import XmlParser, XmlSerializer from .alpinebits_server import ( AlpineBitsAction, AlpineBitsActionName, AlpineBitsClientInfo, AlpineBitsResponse, Version, validate_hotel_authentication, ) from .const import HttpStatusCode from .db import Hotel, HotelInventory, RoomAvailability from .generated import ( ErrorType, InvCountCountType, OtaHotelInvCountNotifRq, OtaHotelInvCountNotifRs, UniqueIdInstance, ) from .logging_config import get_logger _LOGGER = get_logger(__name__) SUPPORTED_CAPABILITIES = [ "OTA_HotelInvCountNotif_accept_rooms", "OTA_HotelInvCountNotif_accept_categories", "OTA_HotelInvCountNotif_accept_deltas", "OTA_HotelInvCountNotif_accept_complete_set", "OTA_HotelInvCountNotif_accept_out_of_order", "OTA_HotelInvCountNotif_accept_out_of_market", "OTA_HotelInvCountNotif_accept_closing_seasons", ] CLOSING_SEASON_TYPE = "__CLOSE" # <= 8 chars per spec SOURCE_FREEROOMS = "FreeRooms" COUNT_TYPE_MAP = { InvCountCountType.VALUE_2: "count_type_2", InvCountCountType.VALUE_6: "count_type_6", InvCountCountType.VALUE_9: "count_type_9", } @dataclass class FreeRoomsProcessingError(Exception): """Custom exception that carries HTTP and OTA error metadata.""" message: str status_code: HttpStatusCode = HttpStatusCode.BAD_REQUEST error_type: ErrorType = ErrorType.VALUE_13 code: str = "450" def __str__(self) -> str: return self.message class FreeRoomsAction(AlpineBitsAction): """Handler for OTA_HotelInvCountNotif:FreeRooms requests.""" def __init__(self, config: dict | None = None): self.name = AlpineBitsActionName.OTA_HOTEL_INV_COUNT_NOTIF_FREE_ROOMS self.version = [Version.V2024_10, Version.V2022_10] self.config = config or {} self.supports = SUPPORTED_CAPABILITIES self._parser = XmlParser() self._serializer = XmlSerializer( config=SerializerConfig( pretty_print=True, xml_declaration=True, encoding="UTF-8", ) ) async def handle( self, action: str, request_xml: str, version: Version, client_info: AlpineBitsClientInfo, dbsession: AsyncSession | None = None, server_capabilities=None, ) -> AlpineBitsResponse: """Process FreeRooms inventory updates.""" try: self._validate_action_name(action) if request_xml is None: raise FreeRoomsProcessingError("Missing request payload") if dbsession is None: raise FreeRoomsProcessingError( "Database session unavailable", HttpStatusCode.INTERNAL_SERVER_ERROR, ) try: request = self._parser.from_string(request_xml, OtaHotelInvCountNotifRq) except Exception as exc: # pragma: no cover - serialization already tested upstream _LOGGER.exception("Failed to parse FreeRooms request: %s", exc) raise FreeRoomsProcessingError("Invalid XML payload") from exc hotel_code = request.inventories.hotel_code if request.inventories else None if not hotel_code: raise FreeRoomsProcessingError("HotelCode attribute is required") if not client_info or not client_info.username or not client_info.password: raise FreeRoomsProcessingError( "Missing authentication context", HttpStatusCode.UNAUTHORIZED, error_type=ErrorType.VALUE_11, code="401", ) if not validate_hotel_authentication( client_info.username, client_info.password, hotel_code, self.config, ): raise FreeRoomsProcessingError( f"Unauthorized FreeRooms notification for hotel {hotel_code}", HttpStatusCode.UNAUTHORIZED, error_type=ErrorType.VALUE_11, code="401", ) hotel = await self._fetch_hotel(dbsession, hotel_code) if hotel is None: raise FreeRoomsProcessingError( f"Hotel {hotel_code} is not provisioned on this server" ) is_complete_set = ( request.unique_id is not None and request.unique_id.instance == UniqueIdInstance.COMPLETE_SET ) update_type = "CompleteSet" if is_complete_set else "Delta" inventory_cache: dict[tuple[str, str | None], HotelInventory] = {} try: if is_complete_set: await self._process_complete_set( dbsession, hotel, request, update_type, inventory_cache ) else: await self._process_delta( dbsession, hotel, request, update_type, inventory_cache ) await dbsession.commit() except FreeRoomsProcessingError: await dbsession.rollback() raise except Exception as exc: # pragma: no cover - defensive await dbsession.rollback() _LOGGER.exception("Unexpected FreeRooms failure: %s", exc) return self._error_response( "Internal server error while processing FreeRooms notification", HttpStatusCode.INTERNAL_SERVER_ERROR, ) _LOGGER.info( "Processed FreeRooms %s update for hotel %s (%d inventory items)", update_type, hotel_code, len(request.inventories.inventory), ) return self._success_response() except FreeRoomsProcessingError as exc: return self._error_response( exc.message, exc.status_code, error_type=exc.error_type, code=exc.code, ) def _validate_action_name(self, action: str) -> None: expected = self.name.value[1] if (action or "").strip() != expected: raise FreeRoomsProcessingError( f"Invalid action {action}, expected {expected}", HttpStatusCode.BAD_REQUEST, ) async def _fetch_hotel(self, session: AsyncSession, hotel_code: str) -> Hotel | None: stmt = select(Hotel).where(Hotel.hotel_id == hotel_code, Hotel.is_active.is_(True)) result = await session.execute(stmt) return result.scalar_one_or_none() async def _process_complete_set( self, session: AsyncSession, hotel: Hotel, request: OtaHotelInvCountNotifRq, update_type: str, inventory_cache: dict[tuple[str, str | None], HotelInventory], ) -> None: await self._delete_existing_availability(session, hotel.hotel_id) await self._process_inventories( session, hotel, request, update_type, inventory_cache, enforce_closing_order=True ) async def _process_delta( self, session: AsyncSession, hotel: Hotel, request: OtaHotelInvCountNotifRq, update_type: str, inventory_cache: dict[tuple[str, str | None], HotelInventory], ) -> None: await self._process_inventories( session, hotel, request, update_type, inventory_cache, enforce_closing_order=False ) async def _delete_existing_availability( self, session: AsyncSession, hotel_id: str, ) -> None: subquery = select(HotelInventory.id).where(HotelInventory.hotel_id == hotel_id) await session.execute( delete(RoomAvailability).where(RoomAvailability.inventory_id.in_(subquery)) ) async def _process_inventories( self, session: AsyncSession, hotel: Hotel, request: OtaHotelInvCountNotifRq, update_type: str, inventory_cache: dict[tuple[str, str | None], HotelInventory], enforce_closing_order: bool, ) -> None: inventories = request.inventories.inventory if request.inventories else [] if not inventories: raise FreeRoomsProcessingError( "Request must include at least one Inventory block", HttpStatusCode.BAD_REQUEST, ) rows_to_upsert: list[dict[str, Any]] = [] now = datetime.now(UTC) encountered_standard = False for inventory in inventories: sac = inventory.status_application_control if sac is None: raise FreeRoomsProcessingError( "StatusApplicationControl element is required for each Inventory", HttpStatusCode.BAD_REQUEST, ) is_closing = self._is_closing_season(sac) if is_closing: if inventory.inv_counts is not None: raise FreeRoomsProcessingError( "Closing seasons cannot contain InvCounts data", HttpStatusCode.BAD_REQUEST, ) if update_type != "CompleteSet": raise FreeRoomsProcessingError( "Closing seasons are only allowed on CompleteSet updates", HttpStatusCode.BAD_REQUEST, ) if enforce_closing_order and encountered_standard: raise FreeRoomsProcessingError( "Closing seasons must appear before other inventory entries", HttpStatusCode.BAD_REQUEST, ) rows_to_upsert.extend( await self._process_closing_season( session, hotel, sac, update_type, now, inventory_cache ) ) continue encountered_standard = True rows_to_upsert.extend( await self._process_inventory_item( session, hotel, sac, inventory.inv_counts, update_type, now, inventory_cache, ) ) await self._upsert_availability_rows(session, rows_to_upsert) async def _process_closing_season( self, session: AsyncSession, hotel: Hotel, sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl, update_type: str, timestamp: datetime, inventory_cache: dict[tuple[str, str | None], HotelInventory], ) -> list[dict[str, Any]]: if sac.inv_type_code or sac.inv_code: raise FreeRoomsProcessingError( "Closing season entries cannot specify InvTypeCode or InvCode", HttpStatusCode.BAD_REQUEST, ) start_date, end_date = self._parse_date_range(sac.start, sac.end) inventory_item = await self._ensure_inventory_item( session, hotel.hotel_id, CLOSING_SEASON_TYPE, None, timestamp, inventory_cache, ) base_payload = { "inventory_id": inventory_item.id, "count_type_2": None, "count_type_6": None, "count_type_9": None, "is_closing_season": True, "last_updated": timestamp, "update_type": update_type, } rows = [] for day in self._iter_days(start_date, end_date): payload = dict(base_payload) payload["date"] = day rows.append(payload) return rows async def _process_inventory_item( self, session: AsyncSession, hotel: Hotel, sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl, inv_counts: ( OtaHotelInvCountNotifRq.Inventories.Inventory.InvCounts | None ), update_type: str, timestamp: datetime, inventory_cache: dict[tuple[str, str | None], HotelInventory], ) -> list[dict[str, Any]]: inv_type_code = (sac.inv_type_code or "").strip() if not inv_type_code: raise FreeRoomsProcessingError( "InvTypeCode is required unless AllInvCode=\"true\"", HttpStatusCode.BAD_REQUEST, ) inv_code = sac.inv_code.strip() if sac.inv_code else None start_date, end_date = self._parse_date_range(sac.start, sac.end) counts = self._extract_counts(inv_counts) base_counts = { "count_type_2": counts.get("count_type_2"), "count_type_6": counts.get("count_type_6"), "count_type_9": counts.get("count_type_9"), } inventory_item = await self._ensure_inventory_item( session, hotel.hotel_id, inv_type_code, inv_code, timestamp, inventory_cache, ) base_payload = { "inventory_id": inventory_item.id, "is_closing_season": False, "last_updated": timestamp, "update_type": update_type, **base_counts, } rows = [] for day in self._iter_days(start_date, end_date): payload = dict(base_payload) payload["date"] = day rows.append(payload) return rows def _parse_date_range(self, start_str: str, end_str: str) -> tuple[date, date]: try: start_date = date.fromisoformat(start_str) end_date = date.fromisoformat(end_str) except ValueError as exc: raise FreeRoomsProcessingError( f"Invalid date format: {exc!s}", HttpStatusCode.BAD_REQUEST, ) from exc if end_date < start_date: raise FreeRoomsProcessingError( "StatusApplicationControl End date cannot be before Start date", HttpStatusCode.BAD_REQUEST, ) return start_date, end_date def _iter_days(self, start_date: date, end_date: date): current = start_date while current <= end_date: yield current current += timedelta(days=1) def _is_closing_season( self, sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl, ) -> bool: return (sac.all_inv_code or "").strip().lower() == "true" def _extract_counts( self, inv_counts: OtaHotelInvCountNotifRq.Inventories.Inventory.InvCounts | None, ) -> dict[str, int | None]: if inv_counts is None or not inv_counts.inv_count: return {} parsed: dict[str, int] = {} for count in inv_counts.inv_count: column_name = COUNT_TYPE_MAP.get(count.count_type) if column_name is None: raise FreeRoomsProcessingError( f"Unsupported CountType {count.count_type}", HttpStatusCode.BAD_REQUEST, ) if column_name in parsed: raise FreeRoomsProcessingError( f"Duplicate CountType {count.count_type.value} detected", HttpStatusCode.BAD_REQUEST, ) try: value = int(count.count) except ValueError as exc: raise FreeRoomsProcessingError( f"Invalid Count value '{count.count}'", HttpStatusCode.BAD_REQUEST, ) from exc if value < 0: raise FreeRoomsProcessingError( "Count values must be non-negative", HttpStatusCode.BAD_REQUEST, ) parsed[column_name] = value return parsed async def _ensure_inventory_item( self, session: AsyncSession, hotel_id: str, inv_type_code: str, inv_code: str | None, timestamp: datetime, cache: dict[tuple[str, str | None], HotelInventory], ) -> HotelInventory: cache_key = (inv_type_code, inv_code) if cache_key in cache: return cache[cache_key] filters = [ HotelInventory.hotel_id == hotel_id, HotelInventory.inv_type_code == inv_type_code, ] if inv_code is None: filters.append(HotelInventory.inv_code.is_(None)) else: filters.append(HotelInventory.inv_code == inv_code) stmt = select(HotelInventory).where(*filters) result = await session.execute(stmt) inventory_item = result.scalar_one_or_none() if inventory_item: inventory_item.last_updated = timestamp else: inventory_item = HotelInventory( hotel_id=hotel_id, inv_type_code=inv_type_code, inv_code=inv_code, source=SOURCE_FREEROOMS, first_seen=timestamp, last_updated=timestamp, ) session.add(inventory_item) await session.flush() cache[cache_key] = inventory_item return inventory_item async def _upsert_availability_rows( self, session: AsyncSession, rows: list[dict[str, Any]], ) -> None: if not rows: return bind = session.get_bind() dialect_name = bind.dialect.name if bind else "" table = RoomAvailability.__table__ if dialect_name == "postgresql": stmt = pg_insert(table).values(rows) stmt = stmt.on_conflict_do_update( index_elements=["inventory_id", "date"], set_=self._build_upsert_set(stmt), ) await session.execute(stmt) return if dialect_name == "sqlite": stmt = sqlite_insert(table).values(rows) stmt = stmt.on_conflict_do_update( index_elements=["inventory_id", "date"], set_=self._build_upsert_set(stmt), ) await session.execute(stmt) return await self._upsert_with_fallback(session, rows) def _build_upsert_set(self, stmt): return { "count_type_2": stmt.excluded.count_type_2, "count_type_6": stmt.excluded.count_type_6, "count_type_9": stmt.excluded.count_type_9, "is_closing_season": stmt.excluded.is_closing_season, "last_updated": stmt.excluded.last_updated, "update_type": stmt.excluded.update_type, } async def _upsert_with_fallback( self, session: AsyncSession, rows: list[dict[str, Any]] ) -> None: for row in rows: stmt = select(RoomAvailability).where( RoomAvailability.inventory_id == row["inventory_id"], RoomAvailability.date == row["date"], ) result = await session.execute(stmt) existing = result.scalar_one_or_none() if existing: existing.count_type_2 = row["count_type_2"] existing.count_type_6 = row["count_type_6"] existing.count_type_9 = row["count_type_9"] existing.is_closing_season = row["is_closing_season"] existing.last_updated = row["last_updated"] existing.update_type = row["update_type"] else: session.add(RoomAvailability(**row)) def _success_response(self) -> AlpineBitsResponse: response = OtaHotelInvCountNotifRs(version="7.000", success="") xml = self._serializer.render( response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"} ) return AlpineBitsResponse(xml, HttpStatusCode.OK) def _error_response( self, message: str, status_code: HttpStatusCode, error_type: ErrorType = ErrorType.VALUE_13, code: str = "450", ) -> AlpineBitsResponse: error = OtaHotelInvCountNotifRs.Errors.Error( type_value=error_type, code=code, content=[message], ) errors = OtaHotelInvCountNotifRs.Errors(error=[error]) response = OtaHotelInvCountNotifRs(version="7.000", errors=errors) xml = self._serializer.render( response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"} ) return AlpineBitsResponse(xml, status_code)