Activated free rooms
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user