Linting and formatting

This commit is contained in:
Jonas Linter
2025-10-07 09:46:44 +02:00
parent b4b7a537e1
commit f0945ed431
21 changed files with 930 additions and 945 deletions

View File

@@ -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