Activated free rooms

This commit is contained in:
Jonas Linter
2025-12-04 15:32:29 +01:00
parent f728ce369a
commit ea3d886b87
8 changed files with 234652 additions and 392882 deletions

View File

@@ -15,6 +15,7 @@ from enum import Enum
from typing import Any, Optional, override
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from xsdata.exceptions import ParserError
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
from alpine_bits_python.alpine_bits_helpers import (
@@ -476,8 +477,12 @@ class ReadAction(AlpineBitsAction):
return AlpineBitsResponse(
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
)
read_request = XmlParser().from_string(request_xml, OtaReadRq)
try:
read_request = XmlParser().from_string(request_xml, OtaReadRq)
except ParserError:
return AlpineBitsResponse(
"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
)
hotel_read_request = read_request.read_requests.hotel_read_request
@@ -837,4 +842,4 @@ class AlpineBitsServer:
# Ensure FreeRoomsAction is registered with ServerCapabilities discovery
# from .free_rooms_action import FreeRoomsAction
from .free_rooms_action import FreeRoomsAction

View File

@@ -16,6 +16,7 @@ from sqlalchemy import (
Index,
Integer,
MetaData,
PrimaryKeyConstraint,
String,
UniqueConstraint,
func,
@@ -750,17 +751,15 @@ class RoomAvailability(Base):
__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)
date = Column(Date, nullable=False)
bookable_type_2 = Column(Integer, nullable=True)
out_of_order_type_6 = Column(Integer, nullable=True)
not_bookable_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)
@@ -768,9 +767,7 @@ class RoomAvailability(Base):
inventory_item = relationship("HotelInventory", back_populates="availability")
__table_args__ = (
UniqueConstraint(
"inventory_id", "date", name="uq_room_availability_unique_key"
),
PrimaryKeyConstraint("inventory_id", "date", name="pk_room_availability"),
)

View File

@@ -48,9 +48,9 @@ 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",
InvCountCountType.VALUE_2: "bookable_type_2",
InvCountCountType.VALUE_6: "out_of_order_type_6",
InvCountCountType.VALUE_9: "not_bookable_type_9",
}
@@ -202,6 +202,107 @@ class FreeRoomsAction(AlpineBitsAction):
result = await session.execute(stmt)
return result.scalar_one_or_none()
def _validate_request(
self,
request: OtaHotelInvCountNotifRq,
update_type: str,
enforce_closing_order: bool,
) -> None:
"""
Validate the entire request before making any database changes.
This performs all validation checks upfront to fail fast and avoid
expensive rollbacks of database operations.
Args:
request: The parsed OTA request
update_type: "CompleteSet" or "Delta"
enforce_closing_order: Whether to enforce closing seasons must come first
Raises:
FreeRoomsProcessingError: If any validation fails
"""
inventories = request.inventories.inventory if request.inventories else []
if not inventories:
raise FreeRoomsProcessingError(
"Request must include at least one Inventory block",
HttpStatusCode.BAD_REQUEST,
)
encountered_standard = False
has_categories = False # Tracks if we've seen category reports (no InvCode)
has_rooms = False # Tracks if we've seen individual room reports (with InvCode)
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)
# Validate closing seasons
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,
)
if sac.inv_type_code or sac.inv_code:
raise FreeRoomsProcessingError(
"Closing season entries cannot specify InvTypeCode or InvCode",
HttpStatusCode.BAD_REQUEST,
)
# Validate date range
self._parse_date_range(sac.start, sac.end)
continue
# Mark that we've seen a non-closing inventory entry
encountered_standard = True
# Validate standard inventory entries
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,
)
# Validate date range
self._parse_date_range(sac.start, sac.end)
# Validate that we don't mix categories and individual rooms
has_inv_code = sac.inv_code is not None and sac.inv_code.strip() != ""
if has_inv_code:
if has_categories:
raise FreeRoomsProcessingError(
"Mixing room categories and individual rooms in one request is not allowed",
HttpStatusCode.BAD_REQUEST,
)
has_rooms = True
else:
if has_rooms:
raise FreeRoomsProcessingError(
"Mixing room categories and individual rooms in one request is not allowed",
HttpStatusCode.BAD_REQUEST,
)
has_categories = True
# Validate counts
self._extract_counts(inventory.inv_counts)
async def _process_complete_set(
self,
session: AsyncSession,
@@ -210,7 +311,13 @@ class FreeRoomsAction(AlpineBitsAction):
update_type: str,
inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> None:
# Validate first before making any database changes
self._validate_request(request, update_type, enforce_closing_order=True)
# Only delete if validation passes
await self._delete_existing_availability(session, hotel.hotel_id)
# Process the validated request
await self._process_inventories(
session, hotel, request, update_type, inventory_cache, enforce_closing_order=True
)
@@ -223,6 +330,10 @@ class FreeRoomsAction(AlpineBitsAction):
update_type: str,
inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> None:
# Validate first before making any database changes
self._validate_request(request, update_type, enforce_closing_order=False)
# Process the validated request
await self._process_inventories(
session, hotel, request, update_type, inventory_cache, enforce_closing_order=False
)
@@ -246,42 +357,23 @@ class FreeRoomsAction(AlpineBitsAction):
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,
)
"""
Process validated inventory data and store in database.
Note: Validation should be done before calling this method via _validate_request().
This method focuses on data transformation and persistence.
"""
inventories = request.inventories.inventory if request.inventories else []
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,
)
continue # Should not happen after validation
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
@@ -289,7 +381,6 @@ class FreeRoomsAction(AlpineBitsAction):
)
continue
encountered_standard = True
rows_to_upsert.extend(
await self._process_inventory_item(
session,
@@ -313,12 +404,7 @@ class FreeRoomsAction(AlpineBitsAction):
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,
)
"""Process a closing season entry. Assumes validation already done."""
start_date, end_date = self._parse_date_range(sac.start, sac.end)
inventory_item = await self._ensure_inventory_item(
session,
@@ -331,9 +417,9 @@ class FreeRoomsAction(AlpineBitsAction):
base_payload = {
"inventory_id": inventory_item.id,
"count_type_2": None,
"count_type_6": None,
"count_type_9": None,
"bookable_type_2": None,
"out_of_order_type_6": None,
"not_bookable_type_9": None,
"is_closing_season": True,
"last_updated": timestamp,
"update_type": update_type,
@@ -358,21 +444,16 @@ class FreeRoomsAction(AlpineBitsAction):
timestamp: datetime,
inventory_cache: dict[tuple[str, str | None], HotelInventory],
) -> list[dict[str, Any]]:
"""Process a standard inventory item. Assumes validation already done."""
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"),
"bookable_type_2": counts.get("bookable_type_2"),
"out_of_order_type_6": counts.get("out_of_order_type_6"),
"not_bookable_type_9": counts.get("not_bookable_type_9"),
}
inventory_item = await self._ensure_inventory_item(
@@ -545,9 +626,9 @@ class FreeRoomsAction(AlpineBitsAction):
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,
"bookable_type_2": stmt.excluded.bookable_type_2,
"out_of_order_type_6": stmt.excluded.out_of_order_type_6,
"not_bookable_type_9": stmt.excluded.not_bookable_type_9,
"is_closing_season": stmt.excluded.is_closing_season,
"last_updated": stmt.excluded.last_updated,
"update_type": stmt.excluded.update_type,
@@ -565,9 +646,9 @@ class FreeRoomsAction(AlpineBitsAction):
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.bookable_type_2 = row["bookable_type_2"]
existing.out_of_order_type_6 = row["out_of_order_type_6"]
existing.not_bookable_type_9 = row["not_bookable_type_9"]
existing.is_closing_season = row["is_closing_season"]
existing.last_updated = row["last_updated"]
existing.update_type = row["update_type"]