Free rooms first implementation

This commit is contained in:
Jonas Linter
2025-11-27 18:57:45 +01:00
parent 23effcebea
commit a07edfe3ec
6 changed files with 1363 additions and 0 deletions

View File

@@ -86,6 +86,10 @@ class AlpineBitsActionName(Enum):
"action_OTA_HotelRatePlan_BaseRates",
"OTA_HotelRatePlan:BaseRates",
)
OTA_HOTEL_INV_COUNT_NOTIF_FREE_ROOMS = (
"action_OTA_HotelInvCountNotif",
"OTA_HotelInvCountNotif:FreeRooms",
)
def __init__(self, capability_name: str, request_name: str):
self.capability_name = capability_name
@@ -819,3 +823,7 @@ class AlpineBitsServer:
return False
return True
# Ensure FreeRoomsAction is registered with ServerCapabilities discovery
from .free_rooms_action import FreeRoomsAction # noqa: E402,F401

View File

@@ -18,6 +18,8 @@ from sqlalchemy import (
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import (
@@ -679,6 +681,66 @@ class ConversionRoom(Base):
conversion = relationship("Conversion", back_populates="conversion_rooms")
class HotelInventory(Base):
"""Room and category definitions synchronized via AlpineBits."""
__tablename__ = "hotel_inventory"
id = Column(Integer, primary_key=True)
hotel_id = Column(
String(50), ForeignKey("hotels.hotel_id", ondelete="CASCADE"), nullable=False, index=True
)
inv_type_code = Column(String(8), nullable=False, index=True)
inv_code = Column(String(16), nullable=True, index=True)
room_name = Column(String(200), nullable=True)
max_occupancy = Column(Integer, nullable=True)
source = Column(String(20), nullable=False)
first_seen = Column(DateTime(timezone=True), nullable=False)
last_updated = Column(DateTime(timezone=True), nullable=False)
hotel = relationship("Hotel", back_populates="inventory_items")
availability = relationship(
"RoomAvailability",
back_populates="inventory_item",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
Index(
"uq_hotel_inventory_unique_key",
"hotel_id",
"inv_type_code",
func.coalesce(inv_code, ""),
unique=True,
),
)
class RoomAvailability(Base):
"""Daily availability counts for inventory items."""
__tablename__ = "room_availability"
id = Column(Integer, primary_key=True)
inventory_id = Column(
Integer, ForeignKey("hotel_inventory.id", ondelete="CASCADE"), nullable=False, index=True
)
date = Column(Date, nullable=False, index=True)
count_type_2 = Column(Integer, nullable=True)
count_type_6 = Column(Integer, nullable=True)
count_type_9 = Column(Integer, nullable=True)
is_closing_season = Column(Boolean, nullable=False, default=False)
last_updated = Column(DateTime(timezone=True), nullable=False)
update_type = Column(String(20), nullable=False)
inventory_item = relationship("HotelInventory", back_populates="availability")
__table_args__ = (
UniqueConstraint("inventory_id", "date", name="uq_room_availability_unique_key"),
)
class Hotel(Base):
"""Hotel configuration (migrated from alpine_bits_auth in config.yaml)."""
@@ -710,6 +772,9 @@ class Hotel(Base):
# Relationships
webhook_endpoints = relationship("WebhookEndpoint", back_populates="hotel")
inventory_items = relationship(
"HotelInventory", back_populates="hotel", cascade="all, delete-orphan"
)
class WebhookEndpoint(Base):

View File

@@ -0,0 +1,600 @@
"""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)