Starting to implement action_OTA_HotelResNotif_GuestRequests. Necessary to fully comply with spec

This commit is contained in:
Jonas Linter
2025-10-01 09:31:11 +02:00
parent 228aed6d58
commit 13df12afc6
7 changed files with 167 additions and 129 deletions

View File

@@ -12,7 +12,7 @@ import difflib
import json
import inspect
import re
from typing import Dict, List, Optional, Any, Union, Tuple, Type
from typing import Dict, List, Optional, Any, Union, Tuple, Type, override
from xml.etree import ElementTree as ET
from dataclasses import dataclass
from enum import Enum, IntEnum
@@ -20,7 +20,6 @@ from enum import Enum, IntEnum
from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_xml_from_db
from .generated.alpinebits import OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq
from xsdata_pydantic.bindings import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig
@@ -37,7 +36,6 @@ logging.basicConfig(level=logging.INFO)
_LOGGER = logging.getLogger(__name__)
class HttpStatusCode(IntEnum):
"""Allowed HTTP status codes for AlpineBits responses."""
@@ -114,6 +112,15 @@ class Version(str, Enum):
# Add other versions as needed
class AlpineBitsClientInfo:
"""Wrapper for username, password, client_id"""
def __init__(self, username: str, password: str, client_id: str | None = None):
self.username = username
self.password = password
self.client_id = client_id
@dataclass
class AlpineBitsResponse:
"""Response data structure for AlpineBits actions."""
@@ -139,7 +146,13 @@ class AlpineBitsAction(ABC):
) # list of versions in case action supports multiple versions
async def handle(
self, action: str, request_xml: str, version: Version, dbsession=None, server_capabilities=None, username=None, password=None, config: Dict = None
self,
action: str,
request_xml: str,
version: Version,
client_info: AlpineBitsClientInfo,
dbsession=None,
server_capabilities=None,
) -> AlpineBitsResponse:
"""
Handle the incoming request XML and return response XML.
@@ -268,7 +281,7 @@ class ServerCapabilities:
class PingAction(AlpineBitsAction):
"""Implementation for OTA_Ping action (handshaking)."""
def __init__(self, config: Dict = None):
def __init__(self, config: Dict = {}):
self.name = AlpineBitsActionName.OTA_PING
self.version = [
Version.V2024_10,
@@ -276,11 +289,13 @@ class PingAction(AlpineBitsAction):
] # Supports multiple versions
self.config = config
@override
async def handle(
self,
action: str,
request_xml: str,
version: Version,
client_info: AlpineBitsClientInfo,
server_capabilities: None | ServerCapabilities = None,
) -> AlpineBitsResponse:
"""Handle ping requests."""
@@ -352,7 +367,7 @@ class PingAction(AlpineBitsAction):
capabilities_json = json.dumps(matching_capabilities, indent=2)
warning = OtaPingRs.Warnings.Warning(
status=WarningStatus.ALPINEBITS_HANDSHAKE.value,
status=WarningStatus.ALPINEBITS_HANDSHAKE,
type_value="11",
content=[capabilities_json],
)
@@ -379,19 +394,24 @@ class PingAction(AlpineBitsAction):
)
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
def strip_control_chars(s):
# Remove all control characters (ASCII < 32 and DEL)
return re.sub(r'[\x00-\x1F\x7F]', '', s)
return re.sub(r"[\x00-\x1F\x7F]", "", s)
def validate_hotel_authentication(username: str, password: str, hotelid: str, config: Dict) -> bool:
""" Validate hotel authentication based on username, password, and hotel ID.
Example config
alpine_bits_auth:
- hotel_id: "123"
hotel_name: "Frangart Inn"
username: "alice"
password: !secret ALICE_PASSWORD
def validate_hotel_authentication(
username: str, password: str, hotelid: str, config: Dict
) -> bool:
"""Validate hotel authentication based on username, password, and hotel ID.
Example config
alpine_bits_auth:
- hotel_id: "123"
hotel_name: "Frangart Inn"
username: "alice"
password: !secret ALICE_PASSWORD
"""
if not config or "alpine_bits_auth" not in config:
@@ -409,20 +429,22 @@ def validate_hotel_authentication(username: str, password: str, hotelid: str, co
# look for hotelid in config
class ReadAction(AlpineBitsAction):
"""Implementation for OTA_Read action."""
def __init__(self, config: Dict = None):
def __init__(self, config: Dict = {}):
self.name = AlpineBitsActionName.OTA_READ
self.version = [Version.V2024_10, Version.V2022_10]
self.config = config
async def handle(
self, action: str, request_xml: str, version: Version, dbsession=None, username=None, password=None
self,
action: str,
request_xml: str,
version: Version,
client_info: AlpineBitsClientInfo,
dbsession=None,
server_capabilities=None,
) -> AlpineBitsResponse:
"""Handle read requests."""
@@ -430,16 +452,16 @@ class ReadAction(AlpineBitsAction):
clean_expected = strip_control_chars(self.name.value[1]).strip()
if clean_action != clean_expected:
return AlpineBitsResponse(
f"Error: Invalid action {action}, expected {self.name.value[1]}", HttpStatusCode.BAD_REQUEST
f"Error: Invalid action {action}, expected {self.name.value[1]}",
HttpStatusCode.BAD_REQUEST,
)
if dbsession is None:
return AlpineBitsResponse(
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
)
read_request = XmlParser().from_string(request_xml, OtaReadRq)
hotel_read_request = read_request.read_requests.hotel_read_request
@@ -450,22 +472,24 @@ class ReadAction(AlpineBitsAction):
if hotelname is None:
hotelname = "unknown"
if username is None or password is None or hotelid is None:
if hotelid is None:
return AlpineBitsResponse(
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials", HttpStatusCode.UNAUTHORIZED
f"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
HttpStatusCode.UNAUTHORIZED,
)
if not validate_hotel_authentication(username, password, hotelid, self.config):
if not validate_hotel_authentication(client_info.username, client_info.password, hotelid, self.config):
return AlpineBitsResponse(
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials", HttpStatusCode.UNAUTHORIZED
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
HttpStatusCode.UNAUTHORIZED,
)
start_date = None
if hotel_read_request.selection_criteria is not None:
start_date = datetime.fromisoformat(hotel_read_request.selection_criteria.start)
start_date = datetime.fromisoformat(
hotel_read_request.selection_criteria.start
)
# query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date
@@ -478,12 +502,18 @@ class ReadAction(AlpineBitsAction):
stmt = stmt.filter(Reservation.start_date >= start_date)
result = await dbsession.execute(stmt)
reservation_customer_pairs: list[tuple[Reservation, Customer]] = result.all() # List of (Reservation, Customer) tuples
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
result.all()
) # List of (Reservation, Customer) tuples
_LOGGER.info(f"Querying reservations and customers for hotel {hotelid} from database")
_LOGGER.info(
f"Querying reservations and customers for hotel {hotelid} from database"
)
for reservation, customer in reservation_customer_pairs:
_LOGGER.info(f"Reservation: {reservation.id}, Customer: {customer.given_name}")
_LOGGER.info(
f"Reservation: {reservation.id}, Customer: {customer.given_name}"
)
res_retrive_rs = create_xml_from_db(reservation_customer_pairs)
config = SerializerConfig(
@@ -495,60 +525,30 @@ class ReadAction(AlpineBitsAction):
)
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
class NotifReportReadAction(AlpineBitsAction):
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
def __init__(self, config: Dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
self.version = [Version.V2024_10, Version.V2022_10]
self.config = config
async def handle(
self,
action: str,
request_xml: str,
version: Version,
dbsession=None,
username=None,
password=None,
) -> AlpineBitsResponse:
"""Handle read requests."""
# For demonstration, just echo back a simple XML response
response_xml = """<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRS xmlns="http://www.opentravel.org/OTA/2003/
05" Version="8.000">
<Success/>
</OTA_ReadRS>"""
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
# class HotelAvailNotifAction(AlpineBitsAction):
# """Implementation for Hotel Availability Notification action with supports."""
# def __init__(self):
# self.name = AlpineBitsActionName.OTA_HOTEL_AVAIL_NOTIF
# self.version = Version.V2022_10
# self.supports = [
# "OTA_HotelAvailNotif_accept_rooms",
# "OTA_HotelAvailNotif_accept_categories",
# "OTA_HotelAvailNotif_accept_deltas",
# "OTA_HotelAvailNotif_accept_BookingThreshold",
# ]
# async def handle(
# self, action: str, request_xml: str, version: Version
# ) -> AlpineBitsResponse:
# """Handle hotel availability notifications."""
# response_xml = """<?xml version="1.0" encoding="UTF-8"?>
# <OTA_HotelAvailNotifRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
# <Success/>
# </OTA_HotelAvailNotifRS>"""
# return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
return AlpineBitsResponse(
f"Error: Action {action} not implemented", HttpStatusCode.BAD_REQUEST
)
class GuestRequestsAction(AlpineBitsAction):
@@ -575,7 +575,6 @@ class AlpineBitsServer:
self._action_instances = {}
self.config = config
self._initialize_action_instances()
def _initialize_action_instances(self):
"""Initialize instances of all discovered action classes."""
@@ -591,7 +590,12 @@ class AlpineBitsServer:
return self.capabilities.get_capabilities_json()
async def handle_request(
self, request_action_name: str, request_xml: str, version: str = "2024-10", dbsession=None, username=None, password=None
self,
request_action_name: str,
request_xml: str,
client_info: AlpineBitsClientInfo,
version: str = "2024-10",
dbsession=None,
) -> AlpineBitsResponse:
"""
Handle an incoming AlpineBits request by routing to appropriate action handler.
@@ -642,11 +646,15 @@ class AlpineBitsServer:
# Special case for ping action - pass server capabilities
if capability_name == "action_OTA_Ping":
return await action_instance.handle(
request_action_name, request_xml, version_enum, self.capabilities
action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info
)
else:
return await action_instance.handle(
request_action_name, request_xml, version_enum, dbsession=dbsession, username=username, password=password
action=request_action_name,
request_xml=request_xml,
version=version_enum,
dbsession=dbsession,
client_info=client_info,
)
except Exception as e:
print(f"Error handling request {request_action_name}: {str(e)}")
@@ -669,7 +677,7 @@ class AlpineBitsServer:
return sorted(request_names)
def is_action_supported(
self, request_action_name: str, version: str = None
self, request_action_name: str, version: str | None = None
) -> bool:
"""
Check if a request action is supported.