Linting and formatting
This commit is contained in:
@@ -1,47 +1,39 @@
|
||||
"""
|
||||
AlpineBits Server for handling hotel data exchange.
|
||||
"""AlpineBits Server for handling hotel data exchange.
|
||||
|
||||
This module provides an asynchronous AlpineBits server that can handle various
|
||||
OTA (OpenTravel Alliance) actions for hotel data exchange. Currently implements
|
||||
handshaking functionality with configurable supported actions and capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
import difflib
|
||||
import json
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Union, Tuple, Type, override
|
||||
from xml.etree import ElementTree as ET
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum, IntEnum
|
||||
from typing import Any, Optional, override
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
|
||||
|
||||
from alpine_bits_python.alpine_bits_helpers import (
|
||||
PhoneTechType,
|
||||
create_res_notif_push_message,
|
||||
create_res_retrieve_response,
|
||||
)
|
||||
|
||||
|
||||
from .db import AckedRequest, Customer, Reservation
|
||||
from .generated.alpinebits import (
|
||||
OtaNotifReportRq,
|
||||
OtaNotifReportRs,
|
||||
OtaPingRq,
|
||||
OtaPingRs,
|
||||
WarningStatus,
|
||||
OtaReadRq,
|
||||
WarningStatus,
|
||||
)
|
||||
from xsdata_pydantic.bindings import XmlSerializer
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from abc import ABC, abstractmethod
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
import logging
|
||||
from .db import AckedRequest, Reservation, Customer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -178,8 +170,7 @@ class AlpineBitsAction(ABC):
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle the incoming request XML and return response XML.
|
||||
"""Handle the incoming request XML and return response XML.
|
||||
|
||||
Default implementation returns "not implemented" error.
|
||||
Override this method in subclasses to provide actual functionality.
|
||||
@@ -191,18 +182,19 @@ class AlpineBitsAction(ABC):
|
||||
|
||||
Returns:
|
||||
AlpineBitsResponse with error or actual response
|
||||
|
||||
"""
|
||||
return_string = f"Error: Action {action} not implemented"
|
||||
return AlpineBitsResponse(return_string, HttpStatusCode.BAD_REQUEST)
|
||||
|
||||
async def check_version_supported(self, version: Version) -> bool:
|
||||
"""
|
||||
Check if the action supports the given version.
|
||||
"""Check if the action supports the given version.
|
||||
|
||||
Args:
|
||||
version: The AlpineBits version to check
|
||||
Returns:
|
||||
True if supported, False otherwise
|
||||
|
||||
"""
|
||||
if isinstance(self.version, list):
|
||||
return version in self.version
|
||||
@@ -210,12 +202,11 @@ class AlpineBitsAction(ABC):
|
||||
|
||||
|
||||
class ServerCapabilities:
|
||||
"""
|
||||
Automatically discovers AlpineBitsAction implementations and generates capabilities.
|
||||
"""Automatically discovers AlpineBitsAction implementations and generates capabilities.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.action_registry: Dict[AlpineBitsActionName, Type[AlpineBitsAction]] = {}
|
||||
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
|
||||
self._discover_actions()
|
||||
self.capability_dict = None
|
||||
|
||||
@@ -236,9 +227,8 @@ class ServerCapabilities:
|
||||
# Use capability attribute as registry key
|
||||
self.action_registry[action_instance.name] = obj
|
||||
|
||||
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
|
||||
"""
|
||||
Check if an action is actually implemented or just uses the default behavior.
|
||||
def _is_action_implemented(self, action_class: type[AlpineBitsAction]) -> bool:
|
||||
"""Check if an action is actually implemented or just uses the default behavior.
|
||||
This is a simple check - in practice, you might want more sophisticated detection.
|
||||
"""
|
||||
# Check if the class has overridden the handle method
|
||||
@@ -247,8 +237,7 @@ class ServerCapabilities:
|
||||
return False
|
||||
|
||||
def create_capabilities_dict(self) -> None:
|
||||
"""
|
||||
Generate the capabilities dictionary based on discovered actions.
|
||||
"""Generate the capabilities dictionary based on discovered actions.
|
||||
|
||||
"""
|
||||
versions_dict = {}
|
||||
@@ -298,18 +287,15 @@ class ServerCapabilities:
|
||||
if action.get("action") != "action_OTA_Ping"
|
||||
]
|
||||
|
||||
return None
|
||||
|
||||
def get_capabilities_dict(self) -> Dict:
|
||||
def get_capabilities_dict(self) -> dict:
|
||||
"""Get capabilities as a dictionary. Generates if not already created.
|
||||
"""
|
||||
Get capabilities as a dictionary. Generates if not already created.
|
||||
"""
|
||||
|
||||
if self.capability_dict is None:
|
||||
self.create_capabilities_dict()
|
||||
return self.capability_dict
|
||||
|
||||
def get_supported_actions(self) -> List[str]:
|
||||
def get_supported_actions(self) -> list[str]:
|
||||
"""Get list of all supported action names."""
|
||||
return list(self.action_registry.keys())
|
||||
|
||||
@@ -320,7 +306,7 @@ class ServerCapabilities:
|
||||
class PingAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Ping action (handshaking)."""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_PING
|
||||
self.version = [
|
||||
Version.V2024_10,
|
||||
@@ -338,10 +324,9 @@ class PingAction(AlpineBitsAction):
|
||||
server_capabilities: None | ServerCapabilities = None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle ping requests."""
|
||||
|
||||
if request_xml is None:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
if server_capabilities is None:
|
||||
@@ -356,9 +341,9 @@ class PingAction(AlpineBitsAction):
|
||||
parsed_request = parser.from_string(request_xml, OtaPingRq)
|
||||
|
||||
echo_data_client = json.loads(parsed_request.echo_data)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
|
||||
"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# compare echo data with capabilities, create a dictionary containing the matching capabilities
|
||||
@@ -441,7 +426,7 @@ def strip_control_chars(s):
|
||||
|
||||
|
||||
def validate_hotel_authentication(
|
||||
username: str, password: str, hotelid: str, config: Dict
|
||||
username: str, password: str, hotelid: str, config: dict
|
||||
) -> bool:
|
||||
"""Validate hotel authentication based on username, password, and hotel ID.
|
||||
|
||||
@@ -452,7 +437,6 @@ def validate_hotel_authentication(
|
||||
username: "alice"
|
||||
password: !secret ALICE_PASSWORD
|
||||
"""
|
||||
|
||||
if not config or "alpine_bits_auth" not in config:
|
||||
return False
|
||||
auth_list = config["alpine_bits_auth"]
|
||||
@@ -471,7 +455,7 @@ def validate_hotel_authentication(
|
||||
class ReadAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Read action."""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_READ
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
@@ -486,7 +470,6 @@ class ReadAction(AlpineBitsAction):
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle read requests."""
|
||||
|
||||
clean_action = strip_control_chars(str(action)).strip()
|
||||
clean_expected = strip_control_chars(self.name.value[1]).strip()
|
||||
|
||||
@@ -513,7 +496,7 @@ class ReadAction(AlpineBitsAction):
|
||||
|
||||
if hotelid is None:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
|
||||
"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
@@ -541,18 +524,17 @@ class ReadAction(AlpineBitsAction):
|
||||
)
|
||||
if start_date:
|
||||
stmt = stmt.filter(Reservation.start_date >= start_date)
|
||||
else:
|
||||
# remove reservations that have been acknowledged via client_id
|
||||
if client_info.client_id:
|
||||
subquery = (
|
||||
select(Reservation.id)
|
||||
.join(
|
||||
AckedRequest,
|
||||
AckedRequest.unique_id == Reservation.unique_id,
|
||||
)
|
||||
.filter(AckedRequest.client_id == client_info.client_id)
|
||||
# remove reservations that have been acknowledged via client_id
|
||||
elif client_info.client_id:
|
||||
subquery = (
|
||||
select(Reservation.id)
|
||||
.join(
|
||||
AckedRequest,
|
||||
AckedRequest.unique_id == Reservation.unique_id,
|
||||
)
|
||||
stmt = stmt.filter(~Reservation.id.in_(subquery))
|
||||
.filter(AckedRequest.client_id == client_info.client_id)
|
||||
)
|
||||
stmt = stmt.filter(~Reservation.id.in_(subquery))
|
||||
|
||||
result = await dbsession.execute(stmt)
|
||||
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
|
||||
@@ -583,7 +565,7 @@ class ReadAction(AlpineBitsAction):
|
||||
class NotifReportReadAction(AlpineBitsAction):
|
||||
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
@@ -598,7 +580,6 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle read requests."""
|
||||
|
||||
notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
|
||||
|
||||
# we can't check hotel auth here, because this action does not contain hotel info
|
||||
@@ -621,34 +602,29 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
|
||||
if warnings is None and notif_report_details is None:
|
||||
return AlpineBitsResponse(
|
||||
response_xml, HttpStatusCode.OK
|
||||
) # Nothing to process
|
||||
elif (
|
||||
if (warnings is None and notif_report_details is None) or (
|
||||
notif_report_details is not None
|
||||
and notif_report_details.hotel_notif_report is None
|
||||
):
|
||||
return AlpineBitsResponse(
|
||||
response_xml, HttpStatusCode.OK
|
||||
) # Nothing to process
|
||||
else:
|
||||
if dbsession is None:
|
||||
return AlpineBitsResponse(
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
if dbsession is None:
|
||||
return AlpineBitsResponse(
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
timestamp = datetime.now(ZoneInfo("UTC"))
|
||||
for entry in notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation: # type: ignore
|
||||
unique_id = entry.unique_id.id
|
||||
acked_request = AckedRequest(
|
||||
unique_id=unique_id,
|
||||
client_id=client_info.client_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
dbsession.add(acked_request)
|
||||
timestamp = datetime.now(ZoneInfo("UTC"))
|
||||
for entry in notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation: # type: ignore
|
||||
unique_id = entry.unique_id.id
|
||||
acked_request = AckedRequest(
|
||||
unique_id=unique_id,
|
||||
client_id=client_info.client_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
dbsession.add(acked_request)
|
||||
|
||||
await dbsession.commit()
|
||||
await dbsession.commit()
|
||||
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
@@ -656,7 +632,7 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
class PushAction(AlpineBitsAction):
|
||||
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests"""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
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
|
||||
@@ -664,14 +640,13 @@ class PushAction(AlpineBitsAction):
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request_xml: Tuple[Reservation, Customer],
|
||||
request_xml: tuple[Reservation, Customer],
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Create push request XML."""
|
||||
|
||||
xml_push_request = create_res_notif_push_message(request_xml)
|
||||
|
||||
config = SerializerConfig(
|
||||
@@ -686,15 +661,14 @@ class PushAction(AlpineBitsAction):
|
||||
|
||||
|
||||
class AlpineBitsServer:
|
||||
"""
|
||||
Asynchronous AlpineBits server for handling hotel data exchange requests.
|
||||
"""Asynchronous AlpineBits server for handling hotel data exchange requests.
|
||||
|
||||
This server handles various OTA actions and implements the AlpineBits protocol
|
||||
for hotel data exchange. It maintains a registry of supported actions and
|
||||
their capabilities, and can respond to handshake requests with its capabilities.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict = None):
|
||||
def __init__(self, config: dict = None):
|
||||
self.capabilities = ServerCapabilities()
|
||||
self._action_instances = {}
|
||||
self.config = config
|
||||
@@ -706,20 +680,19 @@ class AlpineBitsServer:
|
||||
_LOGGER.info(f"Initializing action instance for {capability_name}")
|
||||
self._action_instances[capability_name] = action_class(config=self.config)
|
||||
|
||||
def get_capabilities(self) -> Dict:
|
||||
def get_capabilities(self) -> dict:
|
||||
"""Get server capabilities."""
|
||||
return self.capabilities.get_capabilities_dict()
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
request_action_name: str,
|
||||
request_xml: str | Tuple[Reservation, Customer],
|
||||
request_xml: str | tuple[Reservation, Customer],
|
||||
client_info: AlpineBitsClientInfo,
|
||||
version: str = "2024-10",
|
||||
dbsession=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
"""Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
|
||||
Args:
|
||||
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
|
||||
@@ -728,6 +701,7 @@ class AlpineBitsServer:
|
||||
|
||||
Returns:
|
||||
AlpineBitsResponse with the result
|
||||
|
||||
"""
|
||||
# Convert string version to enum
|
||||
try:
|
||||
@@ -774,7 +748,7 @@ class AlpineBitsServer:
|
||||
action_instance: PushAction
|
||||
if request_xml is None or not isinstance(request_xml, tuple):
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Invalid data for push request",
|
||||
"Error: Invalid data for push request",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
@@ -792,26 +766,25 @@ class AlpineBitsServer:
|
||||
server_capabilities=self.capabilities,
|
||||
client_info=client_info,
|
||||
)
|
||||
else:
|
||||
return await action_instance.handle(
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
dbsession=dbsession,
|
||||
client_info=client_info,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
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)}")
|
||||
print(f"Error handling request {request_action_name}: {e!s}")
|
||||
# print stack trace for debugging
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Internal server error while processing {request_action_name}: {str(e)}",
|
||||
f"Error: Internal server error while processing {request_action_name}: {e!s}",
|
||||
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def get_supported_request_names(self) -> List[str]:
|
||||
def get_supported_request_names(self) -> list[str]:
|
||||
"""Get all supported request names (not capability names)."""
|
||||
request_names = []
|
||||
for capability_name in self._action_instances.keys():
|
||||
@@ -823,8 +796,7 @@ class AlpineBitsServer:
|
||||
def is_action_supported(
|
||||
self, request_action_name: str, version: str | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a request action is supported.
|
||||
"""Check if a request action is supported.
|
||||
|
||||
Args:
|
||||
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
|
||||
@@ -832,6 +804,7 @@ class AlpineBitsServer:
|
||||
|
||||
Returns:
|
||||
True if supported, False otherwise
|
||||
|
||||
"""
|
||||
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
|
||||
if not action_enum:
|
||||
@@ -848,8 +821,7 @@ class AlpineBitsServer:
|
||||
# This would need to be async, but for simplicity we'll just check if version exists
|
||||
if isinstance(action_instance.version, list):
|
||||
return version_enum in action_instance.version
|
||||
else:
|
||||
return action_instance.version == version_enum
|
||||
return action_instance.version == version_enum
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user