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(