From 13df12afc6d5392ccfe71752a83a04c60c0199d5 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 09:31:11 +0200 Subject: [PATCH] Starting to implement action_OTA_HotelResNotif_GuestRequests. Necessary to fully comply with spec --- src/alpine_bits_python/alpine_bits_helpers.py | 55 +++-- src/alpine_bits_python/alpinebits_server.py | 190 +++++++++--------- src/alpine_bits_python/api.py | 25 ++- src/alpine_bits_python/config_loader.py | 17 +- src/alpine_bits_python/const.py | 0 src/alpine_bits_python/db.py | 4 - src/alpine_bits_python/main.py | 5 - 7 files changed, 167 insertions(+), 129 deletions(-) create mode 100644 src/alpine_bits_python/const.py diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index 618c521..5bf677a 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -9,7 +9,13 @@ from typing import Tuple from alpine_bits_python.db import Customer, Reservation # Import the generated classes -from .generated.alpinebits import HotelReservationResStatus, OtaHotelResNotifRq, OtaResRetrieveRs, CommentName2, UniqueIdType2 +from .generated.alpinebits import ( + HotelReservationResStatus, + OtaHotelResNotifRq, + OtaResRetrieveRs, + CommentName2, + UniqueIdType2, +) import logging _LOGGER = logging.getLogger(__name__) @@ -431,7 +437,9 @@ class CommentFactory: @staticmethod def _create_comments( - comments_class: type[RetrieveComments] | type[NotifComments], comment_class: type[RetrieveComment] | type[NotifComment], data: CommentsData + comments_class: type[RetrieveComments] | type[NotifComments], + comment_class: type[RetrieveComment] | type[NotifComment], + data: CommentsData, ) -> Any: """Internal method to create comments of the specified type.""" @@ -440,7 +448,9 @@ class CommentFactory: # Create list items list_items = [] for item_data in comment_data.list_items: - _LOGGER.info(f"Creating list item: value={item_data.value}, list_item={item_data.list_item}, language={item_data.language}") + _LOGGER.info( + f"Creating list item: value={item_data.value}, list_item={item_data.list_item}, language={item_data.language}" + ) list_item = comment_class.ListItem( value=item_data.value, @@ -659,10 +669,10 @@ class AlpineBitsFactory: else: raise ValueError(f"Unsupported object type: {type(obj)}") - + def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): - """ Create RetrievedReservation XML from database entries. + """Create RetrievedReservation XML from database entries. list of pairs (Reservation, Customer) """ @@ -670,11 +680,16 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): reservations_list = [] for reservation, customer in list: - _LOGGER.info(f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}") + _LOGGER.info( + f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}" + ) try: - - phone_numbers = [(customer.phone, PhoneTechType.MOBILE)] if customer.phone is not None else [] + phone_numbers = ( + [(customer.phone, PhoneTechType.MOBILE)] + if customer.phone is not None + else [] + ) customer_data = CustomerData( given_name=customer.given_name, surname=customer.surname, @@ -703,10 +718,8 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): reservation.num_adults, children_ages ) - unique_id_string = reservation.form_id - if len(unique_id_string) > 32: unique_id_string = unique_id_string[:32] # Truncate to 32 characters @@ -717,7 +730,9 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): # TimeSpan time_span = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan( - start=reservation.start_date.isoformat() if reservation.start_date else None, + start=reservation.start_date.isoformat() + if reservation.start_date + else None, end=reservation.end_date.isoformat() if reservation.end_date else None, ) room_stay = ( @@ -781,12 +796,15 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): comments_xml = None if comments: - for c in comments: - _LOGGER.info(f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}") - + _LOGGER.info( + f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}" + ) + comments_data = CommentsData(comments=comments) - comments_xml = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE) + comments_xml = alpine_bits_factory.create( + comments_data, OtaMessageType.RETRIEVE + ) res_global_info = ( OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo( @@ -796,8 +814,6 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): ) ) - - hotel_reservation = OtaResRetrieveRs.ReservationsList.HotelReservation( create_date_time=datetime.now(timezone.utc).isoformat(), res_status=HotelReservationResStatus.REQUESTED, @@ -811,7 +827,9 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): reservations_list.append(hotel_reservation) except Exception as e: - _LOGGER.error(f"Error creating XML for reservation {reservation.form_id} and customer {customer.given_name}: {e}") + _LOGGER.error( + f"Error creating XML for reservation {reservation.form_id} and customer {customer.given_name}: {e}" + ) retrieved_reservations = OtaResRetrieveRs.ReservationsList( hotel_reservation=reservations_list @@ -830,7 +848,6 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): return ota_res_retrieve_rs - # Usage examples if __name__ == "__main__": # Create customer data using simple data class diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index b7bb990..75ccccc 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -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 = """ - - - """ - - - - - - - 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 = """ -# -# -# """ -# 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. diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 222d1cb..98d7d25 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -33,7 +33,7 @@ import json import os import gzip import xml.etree.ElementTree as ET -from .alpinebits_server import AlpineBitsServer, Version +from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version import urllib.parse from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker @@ -64,7 +64,7 @@ async def lifespan(app: FastAPI): except Exception as e: _LOGGER.error(f"Failed to load config: {str(e)}") config = {} - + DATABASE_URL = get_database_url(config) engine = create_async_engine(DATABASE_URL, echo=True) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) @@ -83,6 +83,7 @@ async def lifespan(app: FastAPI): # Optional: Dispose engine on shutdown await engine.dispose() + async def get_async_session(request: Request): async_sessionmaker = request.app.state.async_sessionmaker async with async_sessionmaker() as session: @@ -93,7 +94,7 @@ app = FastAPI( title="Wix Form Handler API", description="Secure API endpoint to receive and process Wix form submissions with authentication and rate limiting", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, ) # Create API router with /api prefix @@ -155,8 +156,6 @@ async def process_form_submission(submission_data: Dict[str, Any]) -> None: _LOGGER.error(f"Error processing form submission: {str(e)}") - - @api_router.get("/") @limiter.limit(DEFAULT_RATE_LIMIT) async def root(request: Request): @@ -512,7 +511,9 @@ def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]: @api_router.post("/alpinebits/server-2024-10") @limiter.limit("60/minute") async def alpinebits_server_handshake( - request: Request, credentials_tupel: tuple = Depends(validate_basic_auth), dbsession=Depends(get_async_session) + request: Request, + credentials_tupel: tuple = Depends(validate_basic_auth), + dbsession=Depends(get_async_session), ): """ AlpineBits server endpoint implementing the handshake protocol. @@ -615,14 +616,22 @@ async def alpinebits_server_handshake( # Get optional request XML request_xml = form_data.get("request") - server = app.state.alpine_bits_server + server: AlpineBitsServer = app.state.alpine_bits_server version = Version.V2024_10 username, password = credentials_tupel + client_info = AlpineBitsClientInfo(username=username, password=password, client_id=client_id) + # Create successful handshake response - response = await server.handle_request(action, request_xml, version, dbsession=dbsession, username=username, password=password) + response = await server.handle_request( + action, + request_xml, + client_info=client_info, + version=version, + dbsession=dbsession, + ) response_xml = response.xml_content diff --git a/src/alpine_bits_python/config_loader.py b/src/alpine_bits_python/config_loader.py index b207b4d..229bda7 100644 --- a/src/alpine_bits_python/config_loader.py +++ b/src/alpine_bits_python/config_loader.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from annotatedyaml.loader import ( HAS_C_LOADER, JSON_TYPE, @@ -12,7 +12,15 @@ from annotatedyaml.loader import ( parse_yaml as parse_annotated_yaml, secret_yaml as annotated_secret_yaml, ) -from voluptuous import Schema, Required, All, Length, PREVENT_EXTRA, MultipleInvalid +from voluptuous import ( + Schema, + Required, + All, + Length, + PREVENT_EXTRA, + MultipleInvalid, + Optional, +) # --- Voluptuous schemas --- database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA) @@ -24,6 +32,11 @@ hotel_auth_schema = Schema( Required("hotel_name"): str, Required("username"): str, Required("password"): str, + Optional("push_endpoint"): { + Required("url"): str, + Required("token"): str, + Optional("username"): str, + }, }, extra=PREVENT_EXTRA, ) diff --git a/src/alpine_bits_python/const.py b/src/alpine_bits_python/const.py new file mode 100644 index 0000000..e69de29 diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py index fc59c5d..8810aca 100644 --- a/src/alpine_bits_python/db.py +++ b/src/alpine_bits_python/db.py @@ -18,9 +18,6 @@ def get_database_url(config=None): return db_url - - - class Customer(Base): __tablename__ = "customers" id = Column(Integer, primary_key=True) @@ -71,7 +68,6 @@ class Reservation(Base): customer = relationship("Customer", back_populates="reservations") - class HashedCustomer(Base): __tablename__ = "hashed_customers" id = Column(Integer, primary_key=True) diff --git a/src/alpine_bits_python/main.py b/src/alpine_bits_python/main.py index 2d349c8..2d51ef4 100644 --- a/src/alpine_bits_python/main.py +++ b/src/alpine_bits_python/main.py @@ -53,7 +53,6 @@ logging.basicConfig(level=logging.INFO) _LOGGER = logging.getLogger(__name__) - async def setup_db(config): DATABASE_URL = get_database_url(config) engine = create_async_engine(DATABASE_URL, echo=True) @@ -67,7 +66,6 @@ async def setup_db(config): return engine, AsyncSessionLocal - async def main(): print("🚀 Starting AlpineBits XML generation script...") # Load config (yaml, annotatedyaml) @@ -92,7 +90,6 @@ async def main(): # # Ensure DB schema is created (async) - engine, AsyncSessionLocal = await setup_db(config) async with engine.begin() as conn: @@ -227,8 +224,6 @@ async def main(): def create_xml_from_db(customer: DBCustomer, reservation: DBReservation): - - # Prepare data for XML phone_numbers = [(customer.phone, PhoneTechType.MOBILE)] if customer.phone else [] customer_data = CustomerData(