From 9f289e4750a2005c4c8d4ce83880a6933c24daba Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 10:15:27 +0200 Subject: [PATCH 01/18] Fixed unique_id issue in reservation table --- src/alpine_bits_python/alpine_bits_helpers.py | 9 +++---- src/alpine_bits_python/alpinebits_server.py | 18 ++++++++++++- src/alpine_bits_python/api.py | 26 +++++++++++-------- src/alpine_bits_python/auth.py | 4 +++ src/alpine_bits_python/db.py | 17 ++++++------ src/alpine_bits_python/main.py | 2 +- 6 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index 5bf677a..1325818 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -681,7 +681,7 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): for reservation, customer in list: _LOGGER.info( - f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}" + f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}" ) try: @@ -718,10 +718,7 @@ 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 + unique_id_string = reservation.unique_id # UniqueID unique_id = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId( @@ -828,7 +825,7 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): except Exception as e: _LOGGER.error( - f"Error creating XML for reservation {reservation.form_id} and customer {customer.given_name}: {e}" + f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}" ) retrieved_reservations = OtaResRetrieveRs.ReservationsList( diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index 75ccccc..2dfb159 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -26,7 +26,7 @@ from xsdata.formats.dataclass.serializers.config import SerializerConfig from abc import ABC, abstractmethod from xsdata_pydantic.bindings import XmlParser import logging -from .db import Reservation, Customer +from .db import AckedRequest, Reservation, Customer from sqlalchemy import select from sqlalchemy.orm import joinedload @@ -493,6 +493,8 @@ class ReadAction(AlpineBitsAction): # query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date + + stmt = ( select(Reservation, Customer) .join(Customer, Reservation.customer_id == Customer.id) @@ -500,6 +502,20 @@ 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) + ) + stmt = stmt.filter(~Reservation.id.in_(subquery)) + + result = await dbsession.execute(stmt) reservation_customer_pairs: list[tuple[Reservation, Customer]] = ( diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 98d7d25..2490f73 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -16,7 +16,7 @@ from .config_loader import load_config from fastapi.responses import HTMLResponse, PlainTextResponse, Response from .models import WixFormSubmission from datetime import datetime, date, timezone -from .auth import validate_api_key, validate_wix_signature, generate_api_key +from .auth import generate_unique_id, validate_api_key, validate_wix_signature, generate_api_key from .rate_limit import ( limiter, webhook_limiter, @@ -280,12 +280,16 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db ("utm_Term", "utm_term"), ("utm_Content", "utm_content"), ] - utm_comment_text = [] - for label, field in utm_fields: - val = data.get(f"field:{field}") or data.get(label) - if val: - utm_comment_text.append(f"{label}: {val}") - utm_comment = ",".join(utm_comment_text) if utm_comment_text else None + + # get submissionId and ensure max length 35. Generate one if not present + + unique_id = data.get("submissionId", generate_unique_id()) + + if len(unique_id) > 35: + # strip to first 35 chars + unique_id = unique_id[:35] + + # use database session @@ -309,19 +313,19 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db name_title=None, ) db.add(db_customer) - await db.commit() - await db.refresh(db_customer) + await db.flush() # This assigns db_customer.id without committing + #await db.refresh(db_customer) + db_reservation = DBReservation( customer_id=db_customer.id, - form_id=data.get("submissionId"), + unique_id=unique_id, start_date=date.fromisoformat(start_date) if start_date else None, end_date=date.fromisoformat(end_date) if end_date else None, num_adults=num_adults, num_children=num_children, children_ages=",".join(str(a) for a in children_ages), offer=offer, - utm_comment=utm_comment, created_at=datetime.now(timezone.utc), utm_source=data.get("field:utm_source"), utm_medium=data.get("field:utm_medium"), diff --git a/src/alpine_bits_python/auth.py b/src/alpine_bits_python/auth.py index 5a7632e..6cb20e0 100644 --- a/src/alpine_bits_python/auth.py +++ b/src/alpine_bits_python/auth.py @@ -30,6 +30,10 @@ if os.getenv("WIX_API_KEY"): if os.getenv("ADMIN_API_KEY"): API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY") +def generate_unique_id() -> str: + """Generate a unique ID with max length 35 characters""" + return secrets.token_urlsafe(26)[:35] # 26 bytes -> 35 chars in base64url + def generate_api_key() -> str: """Generate a secure API key""" diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py index 8810aca..1cc790d 100644 --- a/src/alpine_bits_python/db.py +++ b/src/alpine_bits_python/db.py @@ -44,14 +44,13 @@ class Reservation(Base): __tablename__ = "reservations" id = Column(Integer, primary_key=True) customer_id = Column(Integer, ForeignKey("customers.id")) - form_id = Column(String, unique=True) + unique_id = Column(String(35), unique=True) # max length 35 start_date = Column(Date) end_date = Column(Date) num_adults = Column(Integer) num_children = Column(Integer) children_ages = Column(String) # comma-separated offer = Column(String) - utm_comment = Column(String) created_at = Column(DateTime) # Add all UTM fields and user comment for XML utm_source = Column(String) @@ -68,11 +67,11 @@ class Reservation(Base): customer = relationship("Customer", back_populates="reservations") -class HashedCustomer(Base): - __tablename__ = "hashed_customers" + +# Table for tracking acknowledged requests by client +class AckedRequest(Base): + __tablename__ = 'acked_requests' id = Column(Integer, primary_key=True) - customer_id = Column(Integer) - hashed_email = Column(String) - hashed_phone = Column(String) - hashed_name = Column(String) - redacted_at = Column(DateTime) + client_id = Column(String, index=True) + unique_id = Column(String, index=True) # Should match Reservation.form_id or another unique field + timestamp = Column(DateTime) diff --git a/src/alpine_bits_python/main.py b/src/alpine_bits_python/main.py index 2d51ef4..3112d20 100644 --- a/src/alpine_bits_python/main.py +++ b/src/alpine_bits_python/main.py @@ -256,7 +256,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation): # UniqueID unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId( - type_value=ab.UniqueIdType2.VALUE_14, id=reservation.form_id + type_value=ab.UniqueIdType2.VALUE_14, id=reservation.unique_id ) # TimeSpan -- 2.49.1 From 579db2231f4a84e9a67eb97384f03d7b6d2f74e3 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 11:23:54 +0200 Subject: [PATCH 02/18] Barebones notif works. Doing nothing with warnings at the moment. Not sure what I can do exept log the things --- src/alpine_bits_python/alpinebits_server.py | 71 +++++++++++++++++---- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index 2dfb159..10e7f3e 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -8,6 +8,7 @@ handshaking functionality with configurable supported actions and capabilities. import asyncio from datetime import datetime +from zoneinfo import ZoneInfo import difflib import json import inspect @@ -20,7 +21,7 @@ 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 .generated.alpinebits import OtaNotifReportRq, OtaNotifReportRs, OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq from xsdata_pydantic.bindings import XmlSerializer from xsdata.formats.dataclass.serializers.config import SerializerConfig from abc import ABC, abstractmethod @@ -556,26 +557,74 @@ class NotifReportReadAction(AlpineBitsAction): action: str, request_xml: str, version: Version, + client_info: AlpineBitsClientInfo, dbsession=None, - username=None, - password=None, + server_capabilities=None, ) -> AlpineBitsResponse: """Handle read requests.""" - return AlpineBitsResponse( - f"Error: Action {action} not implemented", HttpStatusCode.BAD_REQUEST + notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq) + + # we can't check hotel auth here, because this action does not contain hotel info + + warnings = notif_report.warnings + notif_report_details = notif_report.notif_details + + success_message = OtaNotifReportRs( + version="7.000", success="" ) + if client_info.client_id is None: + return AlpineBitsResponse( + "ERROR:no valid client id provided", HttpStatusCode.BAD_REQUEST + ) -class GuestRequestsAction(AlpineBitsAction): - """Unimplemented action - will not appear in capabilities.""" + config = SerializerConfig( + pretty_print=True, xml_declaration=True, encoding="UTF-8" + ) + serializer = XmlSerializer(config=config) + response_xml = serializer.render( + success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"} + ) - def __init__(self): - self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS - self.version = Version.V2024_10 + if warnings is None and notif_report_details is None: + return AlpineBitsResponse( + response_xml, HttpStatusCode.OK + ) # Nothing to process + elif 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: - # Note: This class doesn't override the handle method, so it won't be discovered + 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) + + await dbsession.commit() + + + + + + + + + + + return AlpineBitsResponse( + response_xml, HttpStatusCode.OK + ) class AlpineBitsServer: """ -- 2.49.1 From dbfbd53ad90b076ce0ab6fe3fddd57adb160506c Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 12:02:40 +0200 Subject: [PATCH 03/18] Removed unused old experiments --- .../alpinebits_guestrequests.py | 169 ------------------ 1 file changed, 169 deletions(-) delete mode 100644 src/alpine_bits_python/alpinebits_guestrequests.py diff --git a/src/alpine_bits_python/alpinebits_guestrequests.py b/src/alpine_bits_python/alpinebits_guestrequests.py deleted file mode 100644 index 9433626..0000000 --- a/src/alpine_bits_python/alpinebits_guestrequests.py +++ /dev/null @@ -1,169 +0,0 @@ -import xml.etree.ElementTree as ET -from datetime import datetime, timezone -from typing import List, Optional - - -# TimeSpan class according to XSD: -class TimeSpan: - def __init__( - self, - start: str, - end: str = None, - duration: str = None, - start_window: str = None, - end_window: str = None, - ): - self.start = start - self.end = end - self.duration = duration - self.start_window = start_window - self.end_window = end_window - - def to_xml(self): - attrib = {"Start": self.start} - if self.end: - attrib["End"] = self.end - if self.duration: - attrib["Duration"] = self.duration - if self.start_window: - attrib["StartWindow"] = self.start_window - if self.end_window: - attrib["EndWindow"] = self.end_window - return ET.Element(_ns("TimeSpan"), attrib) - - -NAMESPACE = "http://www.opentravel.org/OTA/2003/05" -ET.register_namespace("", NAMESPACE) - - -def _ns(tag): - return f"{{{NAMESPACE}}}{tag}" - - -class ResGuest: - def __init__( - self, - given_name: str, - surname: str, - gender: Optional[str] = None, - birth_date: Optional[str] = None, - language: Optional[str] = None, - name_prefix: Optional[str] = None, - name_title: Optional[str] = None, - email: Optional[str] = None, - address: Optional[dict] = None, - telephones: Optional[list] = None, - ): - self.given_name = given_name - self.surname = surname - self.gender = gender - self.birth_date = birth_date - self.language = language - self.name_prefix = name_prefix - self.name_title = name_title - self.email = email - self.address = address or {} - self.telephones = telephones or [] - - def to_xml(self): - resguest_elem = ET.Element(_ns("ResGuest")) - profiles_elem = ET.SubElement(resguest_elem, _ns("Profiles")) - profileinfo_elem = ET.SubElement(profiles_elem, _ns("ProfileInfo")) - profile_elem = ET.SubElement(profileinfo_elem, _ns("Profile")) - customer_elem = ET.SubElement(profile_elem, _ns("Customer")) - if self.gender: - customer_elem.set("Gender", self.gender) - if self.birth_date: - customer_elem.set("BirthDate", self.birth_date) - if self.language: - customer_elem.set("Language", self.language) - personname_elem = ET.SubElement(customer_elem, _ns("PersonName")) - if self.name_prefix: - ET.SubElement(personname_elem, _ns("NamePrefix")).text = self.name_prefix - ET.SubElement(personname_elem, _ns("GivenName")).text = self.given_name - ET.SubElement(personname_elem, _ns("Surname")).text = self.surname - if self.name_title: - ET.SubElement(personname_elem, _ns("NameTitle")).text = self.name_title - for tel in self.telephones: - tel_elem = ET.SubElement(customer_elem, _ns("Telephone")) - for k, v in tel.items(): - tel_elem.set(k, v) - if self.email: - ET.SubElement(customer_elem, _ns("Email")).text = self.email - if self.address: - address_elem = ET.SubElement(customer_elem, _ns("Address")) - for k, v in self.address.items(): - if k == "CountryName": - country_elem = ET.SubElement(address_elem, _ns("CountryName")) - if isinstance(v, dict): - for ck, cv in v.items(): - country_elem.set(ck, cv) - else: - country_elem.text = v - else: - ET.SubElement(address_elem, _ns(k)).text = v - return resguest_elem - - def __str__(self): - from lxml import etree - - elem = self.to_xml() - xml_bytes = ET.tostring(elem, encoding="utf-8") - parser = etree.XMLParser(remove_blank_text=True) - lxml_elem = etree.fromstring(xml_bytes, parser) - return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode") - - -class RoomStay: - def __init__(self, room_type: str, timespan: TimeSpan, guests: List[ResGuest]): - self.room_type = room_type - self.timespan = timespan - self.guests = guests - - def to_xml(self): - roomstay_elem = ET.Element(_ns("RoomStay")) - ET.SubElement(roomstay_elem, _ns("RoomType")).set( - "RoomTypeCode", self.room_type - ) - roomstay_elem.append(self.timespan.to_xml()) - guests_elem = ET.SubElement(roomstay_elem, _ns("Guests")) - for guest in self.guests: - guests_elem.append(guest.to_xml()) - return roomstay_elem - - -class Reservation: - def __init__( - self, - reservation_id: str, - hotel_code: str, - roomstays: List[RoomStay], - create_time: Optional[str] = None, - ): - self.reservation_id = reservation_id - self.hotel_code = hotel_code - self.roomstays = roomstays - self.create_time = create_time or datetime.now(timezone.utc).isoformat() - - def to_xml(self): - res_elem = ET.Element(_ns("HotelReservation")) - uniqueid_elem = ET.SubElement(res_elem, _ns("UniqueID")) - uniqueid_elem.set("Type", "14") - uniqueid_elem.set("ID", self.reservation_id) - hotel_elem = ET.SubElement(res_elem, _ns("Hotel")) - hotel_elem.set("HotelCode", self.hotel_code) - roomstays_elem = ET.SubElement(res_elem, _ns("RoomStays")) - for rs in self.roomstays: - roomstays_elem.append(rs.to_xml()) - res_elem.set("CreateDateTime", self.create_time) - return res_elem - - def to_xml_string(self): - root = ET.Element( - _ns("OTA_ResRetrieveRS"), - {"Version": "2024-10", "TimeStamp": datetime.now(timezone.utc).isoformat()}, - ) - success_elem = ET.SubElement(root, _ns("Success")) - reservations_list = ET.SubElement(root, _ns("ReservationsList")) - reservations_list.append(self.to_xml()) - return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8") -- 2.49.1 From ea9b6c72e42d725a5900e4c3a7633f91f3d2fea1 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 15:38:23 +0200 Subject: [PATCH 04/18] fixed config --- config/config.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/config.yaml b/config/config.yaml index 5d8fecb..6ff4852 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -10,6 +10,10 @@ alpine_bits_auth: hotel_name: "Frangart Inn" username: "alice" password: !secret ALICE_PASSWORD + push_endpoint: + url: "https://example.com/push" + token: !secret PUSH_TOKEN_ALICE + username: "alice" - hotel_id: "456" hotel_name: "Bemelmans" username: "bob" -- 2.49.1 From 36c32c44d81cd14d00d1979afb6a902d713192a1 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 16:32:15 +0200 Subject: [PATCH 05/18] Created a listener for wix-form to do push actions with but unsure how to best handle it --- pyproject.toml | 1 + src/alpine_bits_python/alpinebits_server.py | 33 ++++++++++--- src/alpine_bits_python/api.py | 54 +++++++++++++++++++++ uv.lock | 30 ++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06a57dc..e628069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "dotenv>=0.9.9", "fastapi>=0.117.1", "generateds>=2.44.3", + "httpx>=0.28.1", "lxml>=6.0.1", "pytest>=8.4.2", "redis>=6.4.0", diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index 10e7f3e..b8c2e09 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -613,18 +613,35 @@ class NotifReportReadAction(AlpineBitsAction): await dbsession.commit() - - - - - - - - return AlpineBitsResponse( response_xml, HttpStatusCode.OK ) + + +class PushAction(AlpineBitsAction): + """Creates the necessary xml for OTA_HotelResNotif:GuestRequests""" + + 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, + client_info: AlpineBitsClientInfo, + dbsession=None, + server_capabilities=None, + ) -> AlpineBitsResponse: + """Create push request XML.""" + + pass + + + class AlpineBitsServer: """ diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 2490f73..16e7032 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -36,6 +36,8 @@ import xml.etree.ElementTree as ET from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version import urllib.parse from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from functools import partial +import httpx from .db import ( Base, @@ -52,9 +54,46 @@ _LOGGER = logging.getLogger(__name__) # HTTP Basic auth for AlpineBits security_basic = HTTPBasic() +from collections import defaultdict + +# --- Simple event dispatcher --- +class EventDispatcher: + def __init__(self): + self.listeners = defaultdict(list) + def register(self, event_name, func): + self.listeners[event_name].append(func) + async def dispatch(self, event_name, *args, **kwargs): + for func in self.listeners[event_name]: + await func(*args, **kwargs) + +event_dispatcher = EventDispatcher() + # Load config at startup +async def push_listener(customer, reservation, hotel, push): + + + server: AlpineBitsServer = app.state.alpine_bits_server + + hotel_id = hotel['hotel_id'] + + + + + + + + + + headers = {"Authorization": f"Bearer {push.get('token','')}"} if push.get('token') else {} + try: + async with httpx.AsyncClient() as client: + resp = await client.post(push["url"], json=payload, headers=headers, timeout=10) + _LOGGER.info(f"Push event fired to {push['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}") + except Exception as e: + _LOGGER.error(f"Push event failed for hotel {hotel['hotel_id']}: {e}") + @asynccontextmanager async def lifespan(app: FastAPI): # Setup DB @@ -68,10 +107,19 @@ async def lifespan(app: FastAPI): DATABASE_URL = get_database_url(config) engine = create_async_engine(DATABASE_URL, echo=True) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + app.state.engine = engine app.state.async_sessionmaker = AsyncSessionLocal app.state.config = config app.state.alpine_bits_server = AlpineBitsServer(config) + app.state.event_dispatcher = event_dispatcher + + # Register push listeners for hotels with push_endpoint + for hotel in config.get("alpine_bits_auth", []): + push = hotel.get("push_endpoint") + if push: + + event_dispatcher.register("form_processed", partial(push_listener, hotel=hotel, push=push)) # Create tables async with engine.begin() as conn: @@ -341,6 +389,12 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db db.add(db_reservation) await db.commit() await db.refresh(db_reservation) + + + # Fire event for listeners (push, etc.) + dispatcher = getattr(request.app.state, "event_dispatcher", None) + if dispatcher: + await dispatcher.dispatch("form_processed", db_customer, db_reservation) return { "status": "success", diff --git a/uv.lock b/uv.lock index b4adb07..f867c49 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ dependencies = [ { name = "dotenv" }, { name = "fastapi" }, { name = "generateds" }, + { name = "httpx" }, { name = "lxml" }, { name = "pytest" }, { name = "redis" }, @@ -43,6 +44,7 @@ requires-dist = [ { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", specifier = ">=0.117.1" }, { name = "generateds", specifier = ">=2.44.3" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "lxml", specifier = ">=6.0.1" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "redis", specifier = ">=6.4.0" }, @@ -286,6 +288,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" -- 2.49.1 From b7afe4f52859b147e597b7b6606ec9365a645f64 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 16:43:50 +0200 Subject: [PATCH 06/18] Fixed some shoddy typing --- src/alpine_bits_python/alpine_bits_helpers.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index 1325818..cb5ef99 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -741,9 +741,18 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): room_stays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays( room_stay=[room_stay], ) + klick_id = reservation.fbclid or reservation.gclid + + + + if klick_id in (None, "", "None"): + klick_id = None + else: # extract string from Column object + klick_id = str(klick_id) + hotel_res_id_data = HotelReservationIdData( res_id_type="13", - res_id_value=reservation.fbclid or reservation.gclid, + res_id_value=klick_id, res_id_source=None, res_id_source_context="99tales", ) @@ -754,9 +763,19 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): hotel_res_ids = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds( hotel_reservation_id=[hotel_res_id] ) + + if reservation.hotel_code is None: + raise ValueError("Reservation hotel_code is None") + else: + hotel_code = str(reservation.hotel_code) + if reservation.hotel_name is None: + hotel_name = None + else: + hotel_name = str(reservation.hotel_name) + basic_property_info = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo( - hotel_code=reservation.hotel_code, - hotel_name=reservation.hotel_name, + hotel_code=hotel_code, + hotel_name=hotel_name, ) # Comments -- 2.49.1 From 277bd1934ea5bcc74caa0e24087b56d286afbae4 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 1 Oct 2025 16:44:47 +0200 Subject: [PATCH 07/18] Fixed empty klick_ids --- src/alpine_bits_python/alpine_bits_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index cb5ef99..a9641d3 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -744,7 +744,7 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): klick_id = reservation.fbclid or reservation.gclid - + # explicitly set klick_id to None otherwise an empty string will be sent if klick_id in (None, "", "None"): klick_id = None else: # extract string from Column object -- 2.49.1 From 9c292a98971fa36400bf4dba3e29689d4d9334ff Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Thu, 2 Oct 2025 11:58:30 +0200 Subject: [PATCH 08/18] FFS notifReport is another special case --- src/alpine_bits_python/alpine_bits_helpers.py | 25 +++++++++++++++++-- src/alpine_bits_python/alpinebits_server.py | 11 ++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index a9641d3..8308116 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -741,9 +741,30 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): room_stays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays( room_stay=[room_stay], ) - klick_id = reservation.fbclid or reservation.gclid - + res_id_source = "website" + + if reservation.fbclid != "": + klick_id = reservation.fbclid + res_id_source = "meta" + elif reservation.gclid != "": + klick_id = reservation.gclid + res_id_source = "google" + + + # explicitly set klick_id to None otherwise an empty string will be sent + if klick_id in (None, "", "None"): + klick_id = None + else: # extract string from Column object + klick_id = str(klick_id) + + hotel_res_id_data = HotelReservationIdData( + res_id_type="13", + res_id_value=klick_id, + res_id_source=res_id_source, + res_id_source_context="99tales", + ) + # explicitly set klick_id to None otherwise an empty string will be sent if klick_id in (None, "", "None"): klick_id = None diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index b8c2e09..a9cfa17 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -53,10 +53,14 @@ class AlpineBitsActionName(Enum): OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking") OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests") OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif") - OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( + OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( ## Push Action for Guest Requests "action_OTA_HotelResNotif_GuestRequests", "OTA_HotelResNotif:GuestRequests", ) + OTA_HOTEL_NOTIF_REPORT = ( + "action_OTA_Read", # if read is supported this is also supported + "OTA_NotifReport:GuestRequests", + ) OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = ( "action_OTA_HotelDescriptiveContentNotif_Inventory", "OTA_HotelDescriptiveContentNotif:Inventory", @@ -548,7 +552,7 @@ 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.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT self.version = [Version.V2024_10, Version.V2022_10] self.config = config @@ -661,6 +665,7 @@ class AlpineBitsServer: def _initialize_action_instances(self): """Initialize instances of all discovered action classes.""" for capability_name, action_class in self.capabilities.action_registry.items(): + _LOGGER.info(f"Initializing action instance for {capability_name}") self._action_instances[capability_name] = action_class(config=self.config) def get_capabilities(self) -> Dict: @@ -700,6 +705,8 @@ class AlpineBitsServer: # Find the action by request name action_enum = AlpineBitsActionName.get_by_request_name(request_action_name) + + _LOGGER.info(f"Handling request for action: {request_action_name} with action enum: {action_enum}") if not action_enum: return AlpineBitsResponse( f"Error: Unknown action {request_action_name}", -- 2.49.1 From 233a682e359ccb01aad7d7b5ce283351b8d34b35 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Thu, 2 Oct 2025 13:43:15 +0200 Subject: [PATCH 09/18] Fixed OTA_NotifReport by matching on entire ActionEnum and not just one action string. Now OTA_NotifReport:GuestRequests is distinct even if its corresponding capability action is technically identical OTA_Read:GuestRequests --- src/alpine_bits_python/alpinebits_server.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index a9cfa17..55f43dc 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -196,7 +196,7 @@ class ServerCapabilities: """ def __init__(self): - self.action_registry: Dict[str, Type[AlpineBitsAction]] = {} + self.action_registry: Dict[AlpineBitsActionName, Type[AlpineBitsAction]] = {} self._discover_actions() self.capability_dict = None @@ -214,8 +214,8 @@ class ServerCapabilities: if self._is_action_implemented(obj): action_instance = obj() if hasattr(action_instance, "name"): - # Use capability name for the registry key - self.action_registry[action_instance.name.capability_name] = obj + # Use capability attribute as registry key + self.action_registry[action_instance.name] = obj def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool: """ @@ -234,7 +234,7 @@ class ServerCapabilities: """ versions_dict = {} - for action_name, action_class in self.action_registry.items(): + for action_enum, action_class in self.action_registry.items(): action_instance = action_class() # Get supported versions for this action @@ -250,7 +250,7 @@ class ServerCapabilities: if version_str not in versions_dict: versions_dict[version_str] = {"version": version_str, "actions": []} - action_dict = {"action": action_name} + action_dict = {"action": action_enum.capability_name} # Add supports field if the action has custom supports if hasattr(action_instance, "supports") and action_instance.supports: @@ -714,14 +714,14 @@ class AlpineBitsServer: ) # Check if we have an implementation for this action - capability_name = action_enum.capability_name - if capability_name not in self._action_instances: + + if action_enum not in self._action_instances: return AlpineBitsResponse( f"Error: Action {request_action_name} is not implemented", HttpStatusCode.BAD_REQUEST, ) - action_instance: AlpineBitsAction = self._action_instances[capability_name] + action_instance: AlpineBitsAction = self._action_instances[action_enum] # Check if the action supports the requested version if not await action_instance.check_version_supported(version_enum): @@ -733,7 +733,7 @@ class AlpineBitsServer: # Handle the request try: # Special case for ping action - pass server capabilities - if capability_name == "action_OTA_Ping": + if action_enum == AlpineBitsActionName.OTA_PING: return await action_instance.handle( action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info ) -- 2.49.1 From 82118a1fa872e05c43f9018c65bda96cf865bb55 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Thu, 2 Oct 2025 14:26:06 +0200 Subject: [PATCH 10/18] Added some tests for Handshakes --- pyproject.toml | 1 + ...d_access.py => test_alpine_bits_helper.py} | 5 +- test/test_alpine_bits_server.py | 0 test/test_alpinebits_server_ping.py | 51 ++++++ test/test_data/Handshake-OTA_PingRQ.xml | 158 ++++++++++++++++++ test/test_data/Handshake-OTA_PingRS.xml | 87 ++++++++++ test/test_discovery.py | 67 -------- uv.lock | 14 ++ 8 files changed, 313 insertions(+), 70 deletions(-) rename test/{test_simplified_access.py => test_alpine_bits_helper.py} (99%) create mode 100644 test/test_alpine_bits_server.py create mode 100644 test/test_alpinebits_server_ping.py create mode 100644 test/test_data/Handshake-OTA_PingRQ.xml create mode 100644 test/test_data/Handshake-OTA_PingRS.xml delete mode 100644 test/test_discovery.py diff --git a/pyproject.toml b/pyproject.toml index e628069..073ea9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.28.1", "lxml>=6.0.1", "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", "redis>=6.4.0", "ruff>=0.13.1", "slowapi>=0.1.9", diff --git a/test/test_simplified_access.py b/test/test_alpine_bits_helper.py similarity index 99% rename from test/test_simplified_access.py rename to test/test_alpine_bits_helper.py index 6b1c96a..c098c50 100644 --- a/test/test_simplified_access.py +++ b/test/test_alpine_bits_helper.py @@ -3,10 +3,9 @@ from typing import Union import sys import os -# Add the src directory to the path so we can import our modules -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from simplified_access import ( + +from alpine_bits_python.alpine_bits_helpers import ( CustomerData, CustomerFactory, ResGuestFactory, diff --git a/test/test_alpine_bits_server.py b/test/test_alpine_bits_server.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_alpinebits_server_ping.py b/test/test_alpinebits_server_ping.py new file mode 100644 index 0000000..3013c40 --- /dev/null +++ b/test/test_alpinebits_server_ping.py @@ -0,0 +1,51 @@ +import pytest +import asyncio +from alpine_bits_python.alpinebits_server import AlpineBitsServer, AlpineBitsClientInfo + +@pytest.mark.asyncio +async def test_ping_action_response_success(): + server = AlpineBitsServer() + with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f: + request_xml = f.read() + client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant") + response = await server.handle_request( + request_action_name="OTA_Ping:Handshaking", + request_xml=request_xml, + client_info=client_info, + version="2024-10" + ) + assert response.status_code == 200 + assert " + + + + + +{ + "versions": [ + { + "version": "2024-10", + "actions": [ + { + "action": "action_OTA_Read" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate" + }, + { + "action": "action_OTA_HotelInvCountNotif", + "supports": [ + "OTA_HotelInvCountNotif_accept_rooms", + "OTA_HotelInvCountNotif_accept_categories", + "OTA_HotelInvCountNotif_accept_deltas", + "OTA_HotelInvCountNotif_accept_out_of_market", + "OTA_HotelInvCountNotif_accept_out_of_order", + "OTA_HotelInvCountNotif_accept_complete_set", + "OTA_HotelInvCountNotif_accept_closing_seasons" + ] + }, + { + "action": "action_OTA_HotelDescriptiveContentNotif_Inventory", + "supports": [ + "OTA_HotelDescriptiveContentNotif_Inventory_use_rooms", + "OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children" + ] + }, + { + "action": "action_OTA_HotelDescriptiveContentNotif_Info" + }, + { + "action": "action_OTA_HotelDescriptiveInfo_Inventory" + }, + { + "action": "action_OTA_HotelDescriptiveInfo_Info" + }, + { + "action": "action_OTA_HotelRatePlanNotif_RatePlans", + "supports": [ + "OTA_HotelRatePlanNotif_accept_ArrivalDOW", + "OTA_HotelRatePlanNotif_accept_DepartureDOW", + "OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule", + "OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule", + "OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule", + "OTA_HotelRatePlanNotif_accept_Supplements", + "OTA_HotelRatePlanNotif_accept_FreeNightsOffers", + "OTA_HotelRatePlanNotif_accept_FamilyOffers", + "OTA_HotelRatePlanNotif_accept_full", + "OTA_HotelRatePlanNotif_accept_overlay", + "OTA_HotelRatePlanNotif_accept_RatePlanJoin", + "OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset", + "OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS" + ] + }, + { + "action": "action_OTA_HotelRatePlan_BaseRates", + "supports": [ + "OTA_HotelRatePlan_BaseRates_deltas" + ] + }, + { + "action": "action_OTA_HotelPostEventNotif_EventReports" + } + ] + }, + { + "version": "2022-10", + "actions": [ + { + "action": "action_OTA_Ping" + }, + { + "action": "action_OTA_Read" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate" + }, + { + "action": "action_OTA_HotelInvCountNotif", + "supports": [ + "OTA_HotelInvCountNotif_accept_rooms", + "OTA_HotelInvCountNotif_accept_categories", + "OTA_HotelInvCountNotif_accept_deltas", + "OTA_HotelInvCountNotif_accept_out_of_market", + "OTA_HotelInvCountNotif_accept_out_of_order", + "OTA_HotelInvCountNotif_accept_complete_set", + "OTA_HotelInvCountNotif_accept_closing_seasons" + ] + }, + { + "action": "action_OTA_HotelDescriptiveContentNotif_Inventory", + "supports": [ + "OTA_HotelDescriptiveContentNotif_Inventory_use_rooms", + "OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children" + ] + }, + { + "action": "action_OTA_HotelDescriptiveContentNotif_Info" + }, + { + "action": "action_OTA_HotelDescriptiveInfo_Inventory" + }, + { + "action": "action_OTA_HotelDescriptiveInfo_Info" + }, + + { + "action": "action_OTA_HotelRatePlanNotif_RatePlans", + "supports": [ + "OTA_HotelRatePlanNotif_accept_ArrivalDOW", + "OTA_HotelRatePlanNotif_accept_DepartureDOW", + "OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule", + "OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule", + "OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule", + "OTA_HotelRatePlanNotif_accept_Supplements", + "OTA_HotelRatePlanNotif_accept_FreeNightsOffers", + "OTA_HotelRatePlanNotif_accept_FamilyOffers", + "OTA_HotelRatePlanNotif_accept_overlay", + "OTA_HotelRatePlanNotif_accept_RatePlanJoin", + "OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset", + "OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS" + ] + } + ] + } + ] +} + + diff --git a/test/test_data/Handshake-OTA_PingRS.xml b/test/test_data/Handshake-OTA_PingRS.xml new file mode 100644 index 0000000..05fc21d --- /dev/null +++ b/test/test_data/Handshake-OTA_PingRS.xml @@ -0,0 +1,87 @@ + + + + + + + + { + "versions": [ + { + "version": "2024-10", + "actions": [ + { + "action": "action_OTA_Read" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests" + } + ] + }, + { + "version": "2022-10", + "actions": [ + { + "action": "action_OTA_Ping" + }, + { + "action": "action_OTA_Read" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests" + } + ] + } + ] +} + + { + "versions": [ + { + "version": "2024-10", + "actions": [ + { + "action": "action_OTA_Read" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests" + }, + { + "action": "action_OTA_Read" + } + ] + }, + { + "version": "2022-10", + "actions": [ + { + "action": "action_OTA_Read" + }, + { + "action": "action_OTA_Ping" + }, + { + "action": "action_OTA_HotelResNotif_GuestRequests" + }, + { + "action": "action_OTA_Read" + } + ] + } + ] +} + \ No newline at end of file diff --git a/test/test_discovery.py b/test/test_discovery.py deleted file mode 100644 index 2eda866..0000000 --- a/test/test_discovery.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick test to demonstrate how the ServerCapabilities automatically -discovers implemented vs unimplemented actions. -""" - -from alpine_bits_python.alpinebits_server import ( - ServerCapabilities, - AlpineBitsAction, - AlpineBitsActionName, - Version, - AlpineBitsResponse, - HttpStatusCode, -) -import asyncio - - -class NewImplementedAction(AlpineBitsAction): - """A new action that IS implemented.""" - - def __init__(self): - self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_INFO_INFO - self.version = Version.V2024_10 - - async def handle( - self, action: str, request_xml: str, version: Version - ) -> AlpineBitsResponse: - """This action is implemented.""" - return AlpineBitsResponse("Implemented!", HttpStatusCode.OK) - - -class NewUnimplementedAction(AlpineBitsAction): - """A new action that is NOT implemented (no handle override).""" - - def __init__(self): - self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO - self.version = Version.V2024_10 - - # Notice: No handle method override - will use default "not implemented" - - -async def main(): - print("🔍 Testing Action Discovery Logic") - print("=" * 50) - - # Create capabilities and see what gets discovered - capabilities = ServerCapabilities() - - print("📋 Actions found by discovery:") - for action_name in capabilities.get_supported_actions(): - print(f" ✅ {action_name}") - - print(f"\n📊 Total discovered: {len(capabilities.get_supported_actions())}") - - # Test the new implemented action - implemented_action = NewImplementedAction() - result = await implemented_action.handle("test", "", Version.V2024_10) - print(f"\n🟢 NewImplementedAction result: {result.xml_content}") - - # Test the unimplemented action (should use default behavior) - unimplemented_action = NewUnimplementedAction() - result = await unimplemented_action.handle("test", "", Version.V2024_10) - print(f"🔴 NewUnimplementedAction result: {result.xml_content}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/uv.lock b/uv.lock index f867c49..b9286a4 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ dependencies = [ { name = "httpx" }, { name = "lxml" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "redis" }, { name = "ruff" }, { name = "slowapi" }, @@ -47,6 +48,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "lxml", specifier = ">=6.0.1" }, { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "redis", specifier = ">=6.4.0" }, { name = "ruff", specifier = ">=0.13.1" }, { name = "slowapi", specifier = ">=0.1.9" }, @@ -559,6 +561,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" -- 2.49.1 From 48aec927948158fd1d5f473420cbb8432f46e1cf Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Thu, 2 Oct 2025 15:34:23 +0200 Subject: [PATCH 11/18] Fixed a small handshaking bug thanks to tests --- src/alpine_bits_python/alpinebits_server.py | 28 +++++++++++++- test/test_alpinebits_server_ping.py | 41 +++++++++++++++++++++ test/test_data/Handshake-OTA_PingRS.xml | 6 --- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index 55f43dc..e4fcf0d 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -260,6 +260,25 @@ class ServerCapabilities: self.capability_dict = {"versions": list(versions_dict.values())} + + # filter duplicates in actions for each version + for version in self.capability_dict["versions"]: + seen_actions = set() + unique_actions = [] + for action in version["actions"]: + if action["action"] not in seen_actions: + seen_actions.add(action["action"]) + unique_actions.append(action) + version["actions"] = unique_actions + + # remove action_OTA_Ping from version 2024-10 + for version in self.capability_dict["versions"]: + if version["version"] == "2024-10": + version["actions"] = [ + action for action in version["actions"] + if action.get("action") != "action_OTA_Ping" + ] + return None def get_capabilities_dict(self) -> Dict: @@ -379,12 +398,17 @@ class PingAction(AlpineBitsAction): warning_response = OtaPingRs.Warnings(warning=[warning]) - all_capabilities = server_capabilities.get_capabilities_json() + + # remove action_OTA_Ping from version 2024-10 + all_capabilities = capabilities_dict + + + all_capabilities_json = json.dumps(all_capabilities, indent=2) response_ota_ping = OtaPingRs( version="7.000", warnings=warning_response, - echo_data=all_capabilities, + echo_data=all_capabilities_json, success="", ) diff --git a/test/test_alpinebits_server_ping.py b/test/test_alpinebits_server_ping.py index 3013c40..bd1c0fc 100644 --- a/test/test_alpinebits_server_ping.py +++ b/test/test_alpinebits_server_ping.py @@ -1,6 +1,47 @@ + import pytest import asyncio from alpine_bits_python.alpinebits_server import AlpineBitsServer, AlpineBitsClientInfo +import re +from xsdata_pydantic.bindings import XmlParser +from alpine_bits_python.generated.alpinebits import OtaPingRs + + + + +def extract_relevant_sections(xml_string): + # Remove version attribute value, keep only presence + # Use the same XmlParser as AlpineBitsServer + parser = XmlParser() + obj = parser.from_string(xml_string, OtaPingRs) + return obj + +@pytest.mark.asyncio +async def test_ping_action_response_matches_expected(): + + with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f: + server = AlpineBitsServer() + with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f: + request_xml = f.read() + with open("test/test_data/Handshake-OTA_PingRS.xml", "r", encoding="utf-8") as f: + expected_xml = f.read() + client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant") + response = await server.handle_request( + request_action_name="OTA_Ping:Handshaking", + request_xml=request_xml, + client_info=client_info, + version="2024-10" + ) + actual_obj = extract_relevant_sections(response.xml_content) + expected_obj = extract_relevant_sections(expected_xml) + # log failures to xml files in test_output for easier debugging + if actual_obj != expected_obj: + with open("test/test_output/actual_ping_response.xml", "w", encoding="utf-8") as f: + f.write(response.xml_content) + with open("test/test_output/expected_ping_response.xml", "w", encoding="utf-8") as f: + f.write(expected_xml) + + assert actual_obj == expected_obj @pytest.mark.asyncio async def test_ping_action_response_success(): diff --git a/test/test_data/Handshake-OTA_PingRS.xml b/test/test_data/Handshake-OTA_PingRS.xml index 05fc21d..555c9c9 100644 --- a/test/test_data/Handshake-OTA_PingRS.xml +++ b/test/test_data/Handshake-OTA_PingRS.xml @@ -59,9 +59,6 @@ }, { "action": "action_OTA_HotelResNotif_GuestRequests" - }, - { - "action": "action_OTA_Read" } ] }, @@ -76,9 +73,6 @@ }, { "action": "action_OTA_HotelResNotif_GuestRequests" - }, - { - "action": "action_OTA_Read" } ] } -- 2.49.1 From 325965bb1067f0c1fa912dd7f523b2d64c310393 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Thu, 2 Oct 2025 15:44:52 +0200 Subject: [PATCH 12/18] Fixed up ping test --- .gitignore | 2 ++ test/test_alpinebits_server_ping.py | 18 +++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b0c767a..211c784 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ wheels/ # ignore test_data content but keep the folder test_data/* +test/test_output/* + # ignore secrets secrets.yaml diff --git a/test/test_alpinebits_server_ping.py b/test/test_alpinebits_server_ping.py index bd1c0fc..27ee031 100644 --- a/test/test_alpinebits_server_ping.py +++ b/test/test_alpinebits_server_ping.py @@ -1,4 +1,5 @@ +import json import pytest import asyncio from alpine_bits_python.alpinebits_server import AlpineBitsServer, AlpineBitsClientInfo @@ -34,14 +35,17 @@ async def test_ping_action_response_matches_expected(): ) actual_obj = extract_relevant_sections(response.xml_content) expected_obj = extract_relevant_sections(expected_xml) - # log failures to xml files in test_output for easier debugging - if actual_obj != expected_obj: - with open("test/test_output/actual_ping_response.xml", "w", encoding="utf-8") as f: - f.write(response.xml_content) - with open("test/test_output/expected_ping_response.xml", "w", encoding="utf-8") as f: - f.write(expected_xml) - assert actual_obj == expected_obj + actual_matches = actual_obj.warnings.warning + + expected_matches = expected_obj.warnings.warning + + assert actual_matches == expected_matches, f"Expected warnings {expected_matches}, got {actual_matches}" + + actual_capabilities = actual_obj.echo_data + expected_capabilities = expected_obj.echo_data + + assert actual_capabilities == expected_capabilities, f"Expected echo data {expected_capabilities}, got {actual_capabilities}" @pytest.mark.asyncio async def test_ping_action_response_success(): -- 2.49.1 From 2944b52d437501119eb500530094585e817b1ba2 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 6 Oct 2025 10:21:41 +0200 Subject: [PATCH 13/18] Super simple email newsletter parsing. Better safe then sorry --- src/alpine_bits_python/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 16e7032..868c930 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -284,7 +284,9 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db contact_id = contact_info.get("contactId") name_prefix = data.get("field:anrede") - email_newsletter = data.get("field:form_field_5a7b", "") != "Non selezionato" + email_newsletter_string = data.get("field:form_field_5a7b", "") + yes_values = {"Selezionato", "Angekreuzt"} + email_newsletter = (email_newsletter_string in yes_values) address_line = None city_name = None postal_code = None -- 2.49.1 From 68e49aab3415a98c42d3028849503de833db0342 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 6 Oct 2025 10:58:05 +0200 Subject: [PATCH 14/18] Made helper methods more userfriendly. Guest requests still works as expected --- .../AlpineBits-HotelData-2024-10.pdf | Bin 1042895 -> 1061334 bytes logs/wix_test_data_20251006_104642.json | 262 ++++++++++ logs/wix_test_data_20251006_105732.json | 262 ++++++++++ src/alpine_bits_python/alpine_bits_helpers.py | 481 ++++++++++-------- src/alpine_bits_python/alpinebits_server.py | 4 +- 5 files changed, 802 insertions(+), 207 deletions(-) create mode 100644 logs/wix_test_data_20251006_104642.json create mode 100644 logs/wix_test_data_20251006_105732.json diff --git a/AlpineBits-HotelData-2024-10/AlpineBits-HotelData-2024-10.pdf b/AlpineBits-HotelData-2024-10/AlpineBits-HotelData-2024-10.pdf index ac562095f5937035c851e1bb02efa73ac5101eaa..d496ecb40c5117154af45ca5c8179780184cdd67 100644 GIT binary patch delta 18077 zcmdU137A#Il@`RU-8WpK@fssy{F=LONYI9+5k`anjtk=C@%nY!rg?2%Kd?1pZqU|j z`i&7IF41vDjEatELFAn-6X=*fO}~;8ufM4{meg zwhz2A&Itp*;cLg)%)ieP8gwbwqe`8twCfFEc?Up8_(J_^aU@uYUJ!? z*N+^zmu+^q<1E8>SZ#KAz_EO1cKF2dso4<#+qU$)Z2Ny6b#&5FsdjQ?J&Yz|?nx(9 z6eZ@IMGNYD>ZnyJb1MUNa~4P=y%@#t%o$_b$2*qe+m;nru@n2w0hY6$W$m}WdDx7x zot4m9u%N5lYR8pQtLFq>YpGOj^W zR$v>R=Zy;ksly1Wa=tmauUfBE>-}Tf=Cm0-{pI6~o~qGVS!i_i_4XS5mD=J;ZGUs} zJWx5#3oSp2jB$}`yP=D3+YH;BT&tAoJ$=>oq*SjAKZ9v=vuTqfC)RrEm6|ze4wgwc zG^wxFiHai(%ba7*nK;p$-B(A4cx-PQYaUYRU2Jep7XOU7T|>z+l3Mp#YG+S1_BY#R zVVJ|PT479XPsCqtv2qe4q;!BK*;=v2H4iH-#3IVy0W*5awZ8touKH1>Ln=LoPc!-c zq)C0r0GZV41cn_3p!x59{5N3$zkyExKeFat*_iq2@Of1Ii*_e z>+Gp^lV(q9I(lmTb-oFf99q)Q_RS-DI_t;r%On*gkkel2FV}ibsQ1;_f$DN@-{I9B z^jcw+h;4xjd`XL}i#L3E!*pX};hG83Y&%fWAMzat6=?;5z)qqbd_K4egB5n5$j6Al zveQhg0fuVsy1oHdK2~N=QmmKaQL0$WAxCq{k>NQWpc}gyIUYIHt2*d40vp_554}d@=+5vZJLX$4R@;wb^cs3# zq2sv;YD3os#)5=UOtuSkmw_@MdSo5mv-nM98v*%=j~k$Dc)seoF-Wg53|B-NG*=SM z-XM(fY_YMzV2Bf`tzkj)Ldbn#3@RO{R&WM0ahw=>3*=x~(-OqEm^--OgFE@MY($U< z*06Ljl_UkK$@2mua(%;#qpU;=w1@9oU=+H9CQr+<0kVz2MXQZka4Id!(9t2500dgD z5j)X1KZnYg8Kls{OcFshn3nVis#0__D(18{Eml)MP={~{>km_bKuv@IK-`ZkA2 z5jqy^T3pSDJJ3xq(Etl#yLnK?L?{ZV^MrQWbvcE?L)maiii3?Qfcq!|y3sOE4RQhG2G#6%DXC(vs9e415;jih$d#0I)3NJL`m23v!6y2k#r-2j3NdCZld8iTTi zOm(nN(-I^rHYdX`A~jHP5q&p*2-vWf)FR4afH@GA0OV79^MQ-VVMOzyq&Pu6@-@XP zY)p|q9~(eKRF{}9HovT}h#y(yWJs?v57V`-a)erk8z&%d8wkBZ;i+}CAR4m3Ns73_ z=uuV}6vogUw?mw3p>&_Xl?ElgdOf}+F+AE(Gd z9D7h>KrjHX#x6A%Gy#r4;ZKUlJ%|`q6Lx!{D1`1w5oQ26qJ6-%;UH-V>h-Y0iv>)#q)9HIQ*!47u4s@75e+fe za66!O5sZ5kW)y%N*fTgPp?He`lykGPQGf#jI0L%TPB2}VjW}~{JA*OwYp=Z9Kw@M< zGSX7DfV2$g4jjCchYn5x*r%-Zut~6mC_7MjC|obeJ%J_)EGHRQAgBOfYhl5pPO$<8 zYzDA=v}ehZ;kixPi<=PF;Uvdxh%pat8|D**l5&`-b3BMThsx#{G0FgYNg>f)xSHfk zoJ!z6YdjxP6H<5^2y%nf2tz*+5a6h01xwY$K@=gX)H;_h`aPFSB*V7NR$3Ij;1Kxzj& z;@O1zV#1x2J@EmiGaL}|G4CWr0OruDVb5+LpZYjTa}YAbp?L)oz;QF68{yMPKk+bs zjHx^Dzd8mgsN zayHxzDtGVJ7pO3Qt<*~)V@dC+9Op-uQ=Ib0rIHh!aD7G}dOOB}nBPb-)(5?+jU;{z ziXCNv4>Errl8mu%|0W7xYCzBlBb$PO|3E>DjARhp^I#!yPvHzx20{kV=xE#1#+Av4 zXbYwQjTWB60Fp<*E1LodkZ$G7c|fFPfPJfKWM9*I#K`=VrMe;!(M84`4x2PX>Hdbk z~BFHj(fGOah)mxp6&dW7CP;C3*%*gGlaze zdy(^YVZ2}n0XTnoc8d#1K88eV9nwH(&lXWoz$*r;2y`piHk`Pw#SIcGfl2Kj2bWyX zrX&U&P&1*#@Ik3wb@;@P2QrkZ7kuH8tVQ)IgVOUowJ^GDS{5)Yk#zxRT9y}CQ(SY(s)7_pp}=T6hO4W8Zkw2#NF7gyGu; z;?Nj@J~IJ6?CcEa#*jS5w^0JQ7l2ZAD-w+X^Cd}9w`cefFj$2422wK$d8+A|rDb`z z9SP;2WF~=+JN0mSeokmY<$;q8DSFe!lxcfHhsL;7!-WVXALBwq|3to|W>hU6mMijj z@oONp&qC-R?mRz%$t(b+YNDhUET4ut%xT65HjCPV3h`jDNn(IYWf9P}r%FSknh5q< z1`=NT)QpBzU*aq-T0VZ1=M}FfqOn92BTOaTUMS&rpu?56C3lENS{h}7S<}vC6-zpA z(o(g!5X!(#ue1#WHsgKzusj&c9wa?2X`^^(1sQVI$uXKCNFai5O35)*i*Q0&_jn4(@${46QD~- zIzDC%f<-v>-~`eo1}2%<2U21+(^9pBkN|9D2wMpIv4POa>k+QRq?Z=ku>>IYmFwRPjkuI}aAiQZEVnlu#88%zka z;~=8B>+`sW%1KLz9`OBZ=K=7Y4uWXPr0qy-OK1^M{lXS-=GE6VBb)q9D#$J zgqV}3wNI<|)D2UjhLyU})l==PRr>oDCAD(JSWxLk5Vys^AC|GRr(DlhNo#pw=>+wL zIeA9u1kS?1tCcSOnfidy!u}IVu_G$wT5Z=$LK<>{MA{?!(Uo{WaD zH3OF|_!eJUh#*V^wgr1Ou_M@biCw{gU{7I>^}fPBaiDNO94Z_UCy{!H`4B5SnYg5I zJMjXArx2GF?jY_IjLisKQCNuRQW%3_rs-Tb>5PfwAs5~Fc6m_^@?96E3v0>Lo+^YM ztHZ@Pp-(d&9c1id$ITH(iB%WRDmSo~e)smB5)ry1XD&D%!Tu~#-O*QD2GGwFxDLHV0gsn0WKwRfmsH!6pTldCPK z!|yq19{9!JgEPJ}+TL!XXTJCSDR0~zEq(8duig00+pC^wJ8J)LPVc#VQtK&ie^frc z?ZV}^9QEkrfyHAI_rR0J&b|JinX9khE&+LA0$0ZkD)&ArgzuDrC7u~jI;Ke<5nD^LokG;J6t8;$w)f82P% zv-gf_|K>T}yCh5h{M7YlTz|_wFE1bQtsT$%@Bevzk4L|GNcT&Z%{k-j^KNpYsz)4r^H{mcsK8yF*h!KWQSX}e{c2=&-~riXU~85!&hED)wtn-$DccF z>azdpK5OB>l*j+w^wnFgc=7e#+KAncdu`>)J&)P;p_dMJ&l`wu-DvLDpYC6?cyRN1 zAKbakKTS;Do^GDGrc@iz)_L25FMZg3SL?5LIdS5xquSe!egCa$ea%khRiN{!XQ;@>e?h(4wmN!ulnzH0Q1q=F^~bD<7Y` z;@}|Dn;!{OkRP-SWI5w^^i4dPhN4ecrbpjat zhY>_e5_kE877<JxJTks5n=n+{XZOD8jJUuYg0%sGIGF(rg`h%}2^(-G4dX1@!I*JhA_053*}c&GEo zxZG?n7DYw7(~`@i%?7l} zsfsd-OTsitLzD<>ZBW#knZ7#ModGN%>p0vnp(`=wirZ=fquvQ^lQirtvYmhU{G;B; zG=_Yspt*`#7ap(x;^hL@KcA>KJgtqeHyAE)=hU}P#RU5ZJ78UF!PeqhARi>>6o92j z*~y}aT7Z-nG+`4`fUpc4gF#gvT8ao4#|$YtK28LgM6LS}6hS1l`xKE^dU(a}Ne8Nr zNnvWR|5c~M2^w3AlXG;8RHul2$`EM+1=EO?!uHikcOM@kl|A~YL!?-LIr&3mu@?c( zg>}YAq0_lhp;I?BL<;+SNT${L$4Jx2;in6cCL0_hg?K{Eg9W@8Z*Yh-!R?4gHja-^ z7$f~`LZsM#8yF+~Y(k_74gXIPBb94;aYufdupjIXT3c!S?+Xw6@%(GV{l56HUs8zs z!LAbvNS9wg*bn!A9sh$NhF$PQhy4EPnQPOtcGfUN?PdH{o=OFD^oQtev zz62wHX{ql&IB4bm19u4$@}b(9qOTClSrF(VvdfYejI>lOdYr~2ajdF24sX6Syd+7M z=inF2U;e5JI+6Z3D0;e^Qud_}2%I9jRJ(mF?R*Mx`Jk=fBYS|qOaMZ{hs)a&4l@s( zrX`MA_`tjbRd@+j09Am8oF(}|gDzqJ;d6E|E49nlGpa#F(!aHfCw=v1#xTc(qMX|%Llk$d^;sTO%n6s`W03sJd zL@066069Q-oH=y-l@$C>Q~+?esf1wwJ(a8jwxTZ!%*MAoE}{8Z2?k_{ELyt>rQF7v zY>7z}BFG3b>JT!1E(*=3REzt|NB(3_`^q4%06#FXT11ynj8R zV(;Ta#eT@htO^yY@URXQOL$m^iX}X(L&Xvj)}dmF2C~Dh{Pn6)MKBJy5Sh#ck^m5*{uhe8?vb39ArL({hL5Ck_d(f4psIOt|rFYZk0S zP&m=y?F|SDO9%^FSbmkQ+^So*ASnFv`+xHAtgX&?_x7K>ar(ep|M-K=-EX{l_vvr_ z_?B}^9UZ%FcF^s~Z=*}!S^oRcw~d{-G+7$<{c*1?uf6Pz`(AqJ$-CPwz4Elx9;s=7-13eCo+#FJ7HI_4EhtfAGTnZ~y7On-6$+zg5p3 zJu}>SyL*4wam{vTzP|F3JKtFO!JF@|-uuG`uHEX{U+(bQE7g;NH{Uz?{dcxK^o;Sl zT^P(7V;Pg;C*UvvUb^9&aHog4q6$?jhV(q!} zSN?aw$Q$ZsE}t-dpB<0f`G%$Mzc6b2z~DbzyXu9}J1uEBf8hM1_juI1@rpYhJ$3rQ zoi{GMaLn)9b~vDO<3(HUK6%9j?~Xp~)Qgt9_UO{?>3iJ9l^H&!n%j+kNxofYk4r}Fsf4^eIrp(&y?+18)1Dmvcs$G8Gk*8=+kbw=nzPS7{hd)ST{-%^CtlzBiP87`c-G~c zFM9L4KfPmo>*{4s4;P76s_bXGE!sx@2?QiHBT9py^3Q(J$n6=ZRk~VmbhaP_30G$v z^8nzVQQf*lB2lUZKW}A>E^UD~A^QktHcX~`6STaqKf?tX=U)05N+!<5A%0ubROv~O zvG}6rFlno)&gThQtn*>{4XF$1R4BfO+-Mjy{7X1Jl%NKA+Cv6G{$`F1>OK7McIbNr zcB%K^ZOw5**YT$qfQqdskXwx3PQh`IuS8w!(IDktgTOdsK&w!P-xLfP1OYbKTFvjt z&!n2Z4e{HqAumSsEjGP~ROjY8TmHd?;(LzxmeM-J-xxX60_FJpiB{T%5<-UG$;0Ar zszexaEp@Ptn&0z>2owT^uz{N1gYRa@gy7fEV>H&^$Jaw<>4%Jf^DkQl&&l{_FB~g0r?t;pcGfG~e(i52 z#8aH0-I)|j>6qe7?r^QPHaqs(CrxRa98QWmCU;DnkMyR>VqPWik1eQpe5qDf8VLVF R3;rpFmVNh~GP9%Q{{bZGkG%i@ delta 39 vcmcci*x~$T`-T?A7N!>F7M2#)7Pc1l7LFFq7OocV7M>Q~7QQX~=U)N NotifGuestCounts: + , message_type: OtaMessageType = OtaMessageType.RETRIEVE) -> NotifGuestCounts: """ - Create a GuestCounts object for OtaHotelResNotifRq. + Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs. :param adults: Number of adults :param kids: List of ages for each kid (optional) :return: GuestCounts instance """ - return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts) + if message_type == OtaMessageType.RETRIEVE: + return GuestCountsFactory._create_guest_counts(adults, kids, RetrieveGuestCounts) + elif message_type == OtaMessageType.NOTIF: + return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts) + else: + raise ValueError(f"Unsupported message type: {message_type}") + - @staticmethod - def create_retrieve_guest_counts( - adults: int, kids: Optional[list[int]] = None - ) -> RetrieveGuestCounts: - """ - Create a GuestCounts object for OtaResRetrieveRs. - :param adults: Number of adults - :param kids: List of ages for each kid (optional) - :return: GuestCounts instance - """ - return GuestCountsFactory._create_guest_counts( - adults, kids, RetrieveGuestCounts - ) @staticmethod def _create_guest_counts( @@ -567,6 +573,9 @@ class ResGuestFactory: return CustomerFactory.from_notif_customer(customer) else: return CustomerFactory.from_retrieve_customer(customer) + + + class AlpineBitsFactory: @@ -669,9 +678,217 @@ class AlpineBitsFactory: else: raise ValueError(f"Unsupported object type: {type(obj)}") + +def create_res_retrieve_response(list: list[Tuple[Reservation, Customer]]): + + """Create RetrievedReservation XML from database entries.""" + + return _create_xml_from_db(list, OtaMessageType.RETRIEVE) + +def create_res_notif_push_message(list: Tuple[Reservation, Customer]): + """Create Reservation Notification XML from database entries.""" + + return _create_xml_from_db(list, OtaMessageType.NOTIF) -def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): +def _process_single_reservation(reservation: Reservation, customer: Customer, message_type: OtaMessageType): + + phone_numbers = ( + [(customer.phone, PhoneTechType.MOBILE)] + if customer.phone is not None + else [] + ) + + customer_data = CustomerData( + given_name=customer.given_name, + surname=customer.surname, + name_prefix=customer.name_prefix, + name_title=customer.name_title, + phone_numbers=phone_numbers, + email_address=customer.email_address, + email_newsletter=customer.email_newsletter, + address_line=customer.address_line, + city_name=customer.city_name, + postal_code=customer.postal_code, + country_code=customer.country_code, + address_catalog=customer.address_catalog, + gender=customer.gender, + birth_date=customer.birth_date, + language=customer.language, + ) + alpine_bits_factory = AlpineBitsFactory() + res_guests = alpine_bits_factory.create_res_guests( + customer_data, message_type + ) + + # Guest counts + children_ages = [int(a) for a in reservation.children_ages.split(",") if a] + guest_counts = GuestCountsFactory.create_guest_counts( + reservation.num_adults, children_ages, message_type + ) + + unique_id_string = reservation.unique_id + + + + if message_type == OtaMessageType.NOTIF: + UniqueId = NotifUniqueId + RoomStays = NotifRoomStays + HotelReservation = NotifHotelReservation + elif message_type == OtaMessageType.RETRIEVE: + UniqueId = RetrieveUniqueId + RoomStays = RetrieveRoomStays + HotelReservation = RetrieveHotelReservation + else: + raise ValueError(f"Unsupported message type: {message_type}") + + # UniqueID + unique_id = UniqueId( + type_value=UniqueIdType2.VALUE_14, id=unique_id_string + ) + + + + # TimeSpan + time_span = RoomStays.RoomStay.TimeSpan( + start=reservation.start_date.isoformat() + if reservation.start_date + else None, + end=reservation.end_date.isoformat() if reservation.end_date else None, + ) + room_stay = ( + RoomStays.RoomStay( + time_span=time_span, + guest_counts=guest_counts, + ) + ) + room_stays = RoomStays( + room_stay=[room_stay], + ) + + res_id_source = "website" + + if reservation.fbclid != "": + klick_id = reservation.fbclid + res_id_source = "meta" + elif reservation.gclid != "": + klick_id = reservation.gclid + res_id_source = "google" + + + # explicitly set klick_id to None otherwise an empty string will be sent + if klick_id in (None, "", "None"): + klick_id = None + else: # extract string from Column object + klick_id = str(klick_id) + + hotel_res_id_data = HotelReservationIdData( + res_id_type="13", + res_id_value=klick_id, + res_id_source=res_id_source, + res_id_source_context="99tales", + ) + + # explicitly set klick_id to None otherwise an empty string will be sent + if klick_id in (None, "", "None"): + klick_id = None + else: # extract string from Column object + klick_id = str(klick_id) + + hotel_res_id_data = HotelReservationIdData( + res_id_type="13", + res_id_value=klick_id, + res_id_source=None, + res_id_source_context="99tales", + ) + + hotel_res_id = alpine_bits_factory.create( + hotel_res_id_data, message_type + ) + hotel_res_ids = HotelReservation.ResGlobalInfo.HotelReservationIds( + hotel_reservation_id=[hotel_res_id] + ) + + if reservation.hotel_code is None: + raise ValueError("Reservation hotel_code is None") + else: + hotel_code = str(reservation.hotel_code) + if reservation.hotel_name is None: + hotel_name = None + else: + hotel_name = str(reservation.hotel_name) + + basic_property_info = HotelReservation.ResGlobalInfo.BasicPropertyInfo( + hotel_code=hotel_code, + hotel_name=hotel_name, + ) + # Comments + + offer_comment = None + if reservation.offer is not None: + offer_comment = CommentData( + name=CommentName2.ADDITIONAL_INFO, + text="Angebot/Offerta", + list_items=[ + CommentListItemData( + value=reservation.offer, + language=customer.language, + list_item="1", + ) + ], + ) + comment = None + if reservation.user_comment: + comment = CommentData( + name=CommentName2.CUSTOMER_COMMENT, + text=reservation.user_comment, + list_items=[ + CommentListItemData( + value="Landing page comment", + language=customer.language, + list_item="1", + ) + ], + ) + comments = [offer_comment, comment] + + # filter out None comments + comments = [c for c in comments if c is not None] + + 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)}" + ) + + comments_data = CommentsData(comments=comments) + comments_xml = alpine_bits_factory.create( + comments_data, OtaMessageType.RETRIEVE + ) + + res_global_info = ( + HotelReservation.ResGlobalInfo( + hotel_reservation_ids=hotel_res_ids, + basic_property_info=basic_property_info, + comments=comments_xml, + ) + ) + + hotel_reservation = HotelReservation( + create_date_time=datetime.now(timezone.utc).isoformat(), + res_status=HotelReservationResStatus.REQUESTED, + room_stay_reservation="true", + unique_id=unique_id, + room_stays=room_stays, + res_guests=res_guests, + res_global_info=res_global_info, + ) + + return hotel_reservation + + +def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Reservation, Customer], type: OtaMessageType): """Create RetrievedReservation XML from database entries. list of pairs (Reservation, Customer) @@ -679,187 +896,20 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): reservations_list = [] - for reservation, customer in list: + # if entries isn't a list wrap the element in a list + + if not isinstance(entries, list): + entries = [entries] + + + for reservation, customer in entries: _LOGGER.info( f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}" ) try: - phone_numbers = ( - [(customer.phone, PhoneTechType.MOBILE)] - if customer.phone is not None - else [] - ) - customer_data = CustomerData( - given_name=customer.given_name, - surname=customer.surname, - name_prefix=customer.name_prefix, - name_title=customer.name_title, - phone_numbers=phone_numbers, - email_address=customer.email_address, - email_newsletter=customer.email_newsletter, - address_line=customer.address_line, - city_name=customer.city_name, - postal_code=customer.postal_code, - country_code=customer.country_code, - address_catalog=customer.address_catalog, - gender=customer.gender, - birth_date=customer.birth_date, - language=customer.language, - ) - alpine_bits_factory = AlpineBitsFactory() - res_guests = alpine_bits_factory.create_res_guests( - customer_data, OtaMessageType.RETRIEVE - ) - # Guest counts - children_ages = [int(a) for a in reservation.children_ages.split(",") if a] - guest_counts = GuestCountsFactory.create_retrieve_guest_counts( - reservation.num_adults, children_ages - ) - - unique_id_string = reservation.unique_id - - # UniqueID - unique_id = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId( - type_value=UniqueIdType2.VALUE_14, id=unique_id_string - ) - - # TimeSpan - time_span = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan( - start=reservation.start_date.isoformat() - if reservation.start_date - else None, - end=reservation.end_date.isoformat() if reservation.end_date else None, - ) - room_stay = ( - OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay( - time_span=time_span, - guest_counts=guest_counts, - ) - ) - room_stays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays( - room_stay=[room_stay], - ) - - res_id_source = "website" - - if reservation.fbclid != "": - klick_id = reservation.fbclid - res_id_source = "meta" - elif reservation.gclid != "": - klick_id = reservation.gclid - res_id_source = "google" - - - # explicitly set klick_id to None otherwise an empty string will be sent - if klick_id in (None, "", "None"): - klick_id = None - else: # extract string from Column object - klick_id = str(klick_id) - - hotel_res_id_data = HotelReservationIdData( - res_id_type="13", - res_id_value=klick_id, - res_id_source=res_id_source, - res_id_source_context="99tales", - ) - - # explicitly set klick_id to None otherwise an empty string will be sent - if klick_id in (None, "", "None"): - klick_id = None - else: # extract string from Column object - klick_id = str(klick_id) - - hotel_res_id_data = HotelReservationIdData( - res_id_type="13", - res_id_value=klick_id, - res_id_source=None, - res_id_source_context="99tales", - ) - - hotel_res_id = alpine_bits_factory.create( - hotel_res_id_data, OtaMessageType.RETRIEVE - ) - hotel_res_ids = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds( - hotel_reservation_id=[hotel_res_id] - ) - - if reservation.hotel_code is None: - raise ValueError("Reservation hotel_code is None") - else: - hotel_code = str(reservation.hotel_code) - if reservation.hotel_name is None: - hotel_name = None - else: - hotel_name = str(reservation.hotel_name) - - basic_property_info = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo( - hotel_code=hotel_code, - hotel_name=hotel_name, - ) - # Comments - - offer_comment = None - if reservation.offer is not None: - offer_comment = CommentData( - name=CommentName2.ADDITIONAL_INFO, - text="Angebot/Offerta", - list_items=[ - CommentListItemData( - value=reservation.offer, - language=customer.language, - list_item="1", - ) - ], - ) - comment = None - if reservation.user_comment: - comment = CommentData( - name=CommentName2.CUSTOMER_COMMENT, - text=reservation.user_comment, - list_items=[ - CommentListItemData( - value="Landing page comment", - language=customer.language, - list_item="1", - ) - ], - ) - comments = [offer_comment, comment] - - # filter out None comments - comments = [c for c in comments if c is not None] - - 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)}" - ) - - comments_data = CommentsData(comments=comments) - comments_xml = alpine_bits_factory.create( - comments_data, OtaMessageType.RETRIEVE - ) - - res_global_info = ( - OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo( - hotel_reservation_ids=hotel_res_ids, - basic_property_info=basic_property_info, - comments=comments_xml, - ) - ) - - hotel_reservation = OtaResRetrieveRs.ReservationsList.HotelReservation( - create_date_time=datetime.now(timezone.utc).isoformat(), - res_status=HotelReservationResStatus.REQUESTED, - room_stay_reservation="true", - unique_id=unique_id, - room_stays=room_stays, - res_guests=res_guests, - res_global_info=res_global_info, - ) + hotel_reservation = _process_single_reservation(reservation, customer, type) reservations_list.append(hotel_reservation) @@ -868,21 +918,42 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}" ) - retrieved_reservations = OtaResRetrieveRs.ReservationsList( - hotel_reservation=reservations_list - ) + if type == OtaMessageType.NOTIF: + retrieved_reservations = OtaHotelResNotifRq.HotelReservations( + hotel_reservation=reservations_list + ) - ota_res_retrieve_rs = OtaResRetrieveRs( - version="7.000", success="", reservations_list=retrieved_reservations - ) + ota_hotel_res_notif_rq = OtaHotelResNotifRq( + version="7.000", hotel_reservations=retrieved_reservations + ) - try: - ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump()) - except Exception as e: - _LOGGER.error(f"Validation error: {e}") - raise + try: + ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump()) + except Exception as e: + _LOGGER.error(f"Validation error: {e}") + raise - return ota_res_retrieve_rs + return ota_hotel_res_notif_rq + elif type == OtaMessageType.RETRIEVE: + + retrieved_reservations = OtaResRetrieveRs.ReservationsList( + hotel_reservation=reservations_list + ) + + ota_res_retrieve_rs = OtaResRetrieveRs( + version="7.000", success="", reservations_list=retrieved_reservations + ) + + try: + ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump()) + except Exception as e: + _LOGGER.error(f"Validation error: {e}") + raise + + return ota_res_retrieve_rs + + else: + raise ValueError(f"Unsupported message type: {type}") # Usage examples diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index e4fcf0d..000cc10 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -18,7 +18,7 @@ from xml.etree import ElementTree as ET from dataclasses import dataclass from enum import Enum, IntEnum -from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_xml_from_db +from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_res_retrieve_response from .generated.alpinebits import OtaNotifReportRq, OtaNotifReportRs, OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq @@ -559,7 +559,7 @@ class ReadAction(AlpineBitsAction): f"Reservation: {reservation.id}, Customer: {customer.given_name}" ) - res_retrive_rs = create_xml_from_db(reservation_customer_pairs) + res_retrive_rs = create_res_retrieve_response(reservation_customer_pairs) config = SerializerConfig( pretty_print=True, xml_declaration=True, encoding="UTF-8" -- 2.49.1 From 87668e6dc0fcb596d713d8cabaafa983c0ab21fa Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 6 Oct 2025 11:09:08 +0200 Subject: [PATCH 15/18] Unhappy with push_listener --- src/alpine_bits_python/api.py | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 868c930..b7b634e 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -71,26 +71,31 @@ event_dispatcher = EventDispatcher() # Load config at startup -async def push_listener(customer, reservation, hotel, push): +async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel): + + push_endpoint = hotel.get("push_endpoint") server: AlpineBitsServer = app.state.alpine_bits_server hotel_id = hotel['hotel_id'] - - - - - - - - + reservation_hotel_id = reservation.hotel_code - headers = {"Authorization": f"Bearer {push.get('token','')}"} if push.get('token') else {} + + action = "OTA_HotelResNotifRQ" + + # request = server.handle_request( + # action,) + + + + + + headers = {"Authorization": f"Bearer {push_endpoint.get('token','')}"} if push_endpoint.get('token') else {} try: async with httpx.AsyncClient() as client: - resp = await client.post(push["url"], json=payload, headers=headers, timeout=10) - _LOGGER.info(f"Push event fired to {push['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}") + resp = await client.post(push_endpoint["url"], json=payload, headers=headers, timeout=10) + _LOGGER.info(f"Push event fired to {push_endpoint['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}") except Exception as e: _LOGGER.error(f"Push event failed for hotel {hotel['hotel_id']}: {e}") @@ -114,12 +119,13 @@ async def lifespan(app: FastAPI): app.state.alpine_bits_server = AlpineBitsServer(config) app.state.event_dispatcher = event_dispatcher + # Register push listeners for hotels with push_endpoint for hotel in config.get("alpine_bits_auth", []): - push = hotel.get("push_endpoint") - if push: + push_endpoint = hotel.get("push_endpoint") + if push_endpoint: - event_dispatcher.register("form_processed", partial(push_listener, hotel=hotel, push=push)) + event_dispatcher.register("form_processed", partial(push_listener, hotel=hotel)) # Create tables async with engine.begin() as conn: -- 2.49.1 From 17c3fc57b205a5927817aec1906727e05eb7abde Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 6 Oct 2025 11:47:28 +0200 Subject: [PATCH 16/18] Push requests should be mostly done --- config/config.yaml | 4 +- src/alpine_bits_python/alpine_bits_helpers.py | 2 +- src/alpine_bits_python/alpinebits_server.py | 36 +++++- src/alpine_bits_python/api.py | 104 +++++++++++++++--- 4 files changed, 120 insertions(+), 26 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 6ff4852..85111c3 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -6,8 +6,8 @@ database: # url: "postgresql://user:password@host:port/dbname" # Example for Postgres alpine_bits_auth: - - hotel_id: "123" - hotel_name: "Frangart Inn" + - hotel_id: "12345" + hotel_name: "Bemelmans Post" username: "alice" password: !secret ALICE_PASSWORD push_endpoint: diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index 037dfdf..ee5c3ea 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -864,7 +864,7 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me comments_data = CommentsData(comments=comments) comments_xml = alpine_bits_factory.create( - comments_data, OtaMessageType.RETRIEVE + comments_data, message_type ) res_global_info = ( diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index 000cc10..10afc8e 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -18,7 +18,7 @@ from xml.etree import ElementTree as ET from dataclasses import dataclass from enum import Enum, IntEnum -from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_res_retrieve_response +from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_res_notif_push_message, create_res_retrieve_response from .generated.alpinebits import OtaNotifReportRq, OtaNotifReportRs, OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq @@ -658,7 +658,7 @@ class PushAction(AlpineBitsAction): async def handle( self, action: str, - request_xml: str, + request_xml: Tuple[Reservation, Customer], version: Version, client_info: AlpineBitsClientInfo, dbsession=None, @@ -666,7 +666,18 @@ class PushAction(AlpineBitsAction): ) -> AlpineBitsResponse: """Create push request XML.""" - pass + xml_push_request = create_res_notif_push_message(request_xml) + + + config = SerializerConfig( + pretty_print=True, xml_declaration=True, encoding="UTF-8" + ) + serializer = XmlSerializer(config=config) + xml_push_request = serializer.render( + xml_push_request, ns_map={None: "http://www.opentravel.org/OTA/2003/05"} + ) + + return AlpineBitsResponse(xml_push_request, HttpStatusCode.OK) @@ -703,7 +714,7 @@ class AlpineBitsServer: async def handle_request( self, request_action_name: str, - request_xml: str, + request_xml: str | Tuple[Reservation, Customer], client_info: AlpineBitsClientInfo, version: str = "2024-10", dbsession=None, @@ -713,7 +724,7 @@ class AlpineBitsServer: Args: request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests") - request_xml: The XML request body + request_xml: The XML request body. Gets passed to the action handler. In case of PushRequest can be the data to be pushed version: The AlpineBits version (defaults to "2024-10") Returns: @@ -757,11 +768,26 @@ class AlpineBitsServer: # Handle the request try: # Special case for ping action - pass server capabilities + + if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS: + + action_instance: PushAction + if request_xml is None or not isinstance(request_xml, tuple): + return AlpineBitsResponse( + f"Error: Invalid data for push request", + HttpStatusCode.BAD_REQUEST, + ) + return await action_instance.handle( + action=request_action_name, request_xml=request_xml, version=version_enum, client_info=client_info + ) + + if action_enum == AlpineBitsActionName.OTA_PING: return await action_instance.handle( 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( action=request_action_name, request_xml=request_xml, diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index b7b634e..68381ea 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 AlpineBitsClientInfo, AlpineBitsServer, Version +from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version, AlpineBitsActionName import urllib.parse from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from functools import partial @@ -56,15 +56,28 @@ security_basic = HTTPBasic() from collections import defaultdict -# --- Simple event dispatcher --- +# --- Enhanced event dispatcher with hotel-specific routing --- class EventDispatcher: def __init__(self): self.listeners = defaultdict(list) + self.hotel_listeners = defaultdict(list) # hotel_code -> list of listeners + def register(self, event_name, func): self.listeners[event_name].append(func) + + def register_hotel_listener(self, event_name, hotel_code, func): + """Register a listener for a specific hotel""" + self.hotel_listeners[f"{event_name}:{hotel_code}"].append(func) + async def dispatch(self, event_name, *args, **kwargs): for func in self.listeners[event_name]: await func(*args, **kwargs) + + async def dispatch_for_hotel(self, event_name, hotel_code, *args, **kwargs): + """Dispatch event only to listeners registered for specific hotel""" + key = f"{event_name}:{hotel_code}" + for func in self.hotel_listeners[key]: + await func(*args, **kwargs) event_dispatcher = EventDispatcher() @@ -72,34 +85,56 @@ event_dispatcher = EventDispatcher() async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel): - + """ + Push listener that sends reservation data to hotel's push endpoint. + Only called for reservations that match this hotel's hotel_id. + """ push_endpoint = hotel.get("push_endpoint") - + if not push_endpoint: + _LOGGER.warning(f"No push endpoint configured for hotel {hotel.get('hotel_id')}") + return server: AlpineBitsServer = app.state.alpine_bits_server - hotel_id = hotel['hotel_id'] reservation_hotel_id = reservation.hotel_code + # Double-check hotel matching (should be guaranteed by dispatcher) + if hotel_id != reservation_hotel_id: + _LOGGER.warning(f"Hotel ID mismatch: listener for {hotel_id}, reservation for {reservation_hotel_id}") + return - action = "OTA_HotelResNotifRQ" - - # request = server.handle_request( - # action,) + _LOGGER.info(f"Processing push notification for hotel {hotel_id}, reservation {reservation.unique_id}") + + # Prepare payload for push notification + request = await server.handle_request(request_action_name=AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS.request_name, request_xml=(reservation, customer), client_info=None, version=Version.V2024_10) + if request.status_code != 200: + _LOGGER.error(f"Failed to generate push request for hotel {hotel_id}, reservation {reservation.unique_id}: {request.xml_content}") + return + + print(request.xml_content) + # TODO: Generate AlpineBits OTA_HotelResNotifRQ request + # action = "OTA_HotelResNotifRQ" + # request = server.handle_request(action, ...) + print(f"--- Push Payload --- received. Sending to endpoint., hotelid {hotel_id}, reservation {reservation.unique_id}") + return headers = {"Authorization": f"Bearer {push_endpoint.get('token','')}"} if push_endpoint.get('token') else {} + "" try: async with httpx.AsyncClient() as client: resp = await client.post(push_endpoint["url"], json=payload, headers=headers, timeout=10) _LOGGER.info(f"Push event fired to {push_endpoint['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}") + + if resp.status_code not in [200, 201, 202]: + _LOGGER.warning(f"Push endpoint returned non-success status {resp.status_code}: {resp.text}") + except Exception as e: _LOGGER.error(f"Push event failed for hotel {hotel['hotel_id']}: {e}") - -@asynccontextmanager + # Optionally implement retry logic here@asynccontextmanager async def lifespan(app: FastAPI): # Setup DB @@ -123,9 +158,20 @@ async def lifespan(app: FastAPI): # Register push listeners for hotels with push_endpoint for hotel in config.get("alpine_bits_auth", []): push_endpoint = hotel.get("push_endpoint") - if push_endpoint: - - event_dispatcher.register("form_processed", partial(push_listener, hotel=hotel)) + hotel_id = hotel.get("hotel_id") + + if push_endpoint and hotel_id: + # Register hotel-specific listener + event_dispatcher.register_hotel_listener( + "form_processed", + hotel_id, + partial(push_listener, hotel=hotel) + ) + _LOGGER.info(f"Registered push listener for hotel {hotel_id} with endpoint {push_endpoint.get('url')}") + elif push_endpoint and not hotel_id: + _LOGGER.warning(f"Hotel has push_endpoint but no hotel_id: {hotel}") + elif hotel_id and not push_endpoint: + _LOGGER.info(f"Hotel {hotel_id} has no push_endpoint configured") # Create tables async with engine.begin() as conn: @@ -373,6 +419,22 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db #await db.refresh(db_customer) + # Determine hotel_code and hotel_name + # Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback + hotel_code = ( + data.get("field:hotelid") or + data.get("hotelid") or + request.app.state.config.get("default_hotel_code") or + "123" # fallback + ) + + hotel_name = ( + data.get("field:hotelname") or + data.get("hotelname") or + request.app.state.config.get("default_hotel_name") or + "Frangart Inn" # fallback + ) + db_reservation = DBReservation( customer_id=db_customer.id, unique_id=unique_id, @@ -391,18 +453,24 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db user_comment=data.get("field:long_answer_3524", ""), fbclid=data.get("field:fbclid"), gclid=data.get("field:gclid"), - hotel_code="123", - hotel_name="Frangart Inn", + hotel_code=hotel_code, + hotel_name=hotel_name, ) db.add(db_reservation) await db.commit() await db.refresh(db_reservation) - # Fire event for listeners (push, etc.) + # Fire event for listeners (push, etc.) - hotel-specific dispatch dispatcher = getattr(request.app.state, "event_dispatcher", None) if dispatcher: - await dispatcher.dispatch("form_processed", db_customer, db_reservation) + # Get hotel_code from reservation to target the right listeners + hotel_code = getattr(db_reservation, 'hotel_code', None) + if hotel_code and hotel_code.strip(): + await dispatcher.dispatch_for_hotel("form_processed", hotel_code, db_customer, db_reservation) + _LOGGER.info(f"Dispatched form_processed event for hotel {hotel_code}") + else: + _LOGGER.warning("No hotel_code in reservation, skipping push notifications") return { "status": "success", -- 2.49.1 From b8e4f4fd0119b17e36cb0131a3c2d5b84a07cd53 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 6 Oct 2025 14:46:58 +0200 Subject: [PATCH 17/18] Merging to main --- src/alpine_bits_python/alpine_bits_helpers.py | 35 ++++++++++++- src/alpine_bits_python/api.py | 51 ++++++++++++------- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index ee5c3ea..e09cadd 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +import traceback from typing import Union, Optional, Any, TypeVar from pydantic import BaseModel, ConfigDict, Field from dataclasses import dataclass @@ -14,6 +15,7 @@ from .generated.alpinebits import ( OtaHotelResNotifRq, OtaResRetrieveRs, CommentName2, + ProfileProfileType, UniqueIdType2, ) import logging @@ -735,10 +737,12 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me UniqueId = NotifUniqueId RoomStays = NotifRoomStays HotelReservation = NotifHotelReservation + Profile = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile elif message_type == OtaMessageType.RETRIEVE: UniqueId = RetrieveUniqueId RoomStays = RetrieveRoomStays HotelReservation = RetrieveHotelReservation + Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile else: raise ValueError(f"Unsupported message type: {message_type}") @@ -795,10 +799,24 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me else: # extract string from Column object klick_id = str(klick_id) + + + + + utm_medium = ( + str(reservation.utm_medium) + if reservation.utm_medium is not None and str(reservation.utm_medium) != "" + else "website" + ) + + #shorten klick_id if longer than 64 characters + if klick_id is not None and len(klick_id) > 64: + klick_id = klick_id[:64] + hotel_res_id_data = HotelReservationIdData( res_id_type="13", res_id_value=klick_id, - res_id_source=None, + res_id_source=utm_medium, res_id_source_context="99tales", ) @@ -866,12 +884,26 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me comments_xml = alpine_bits_factory.create( comments_data, message_type ) + + + company_name = Profile.CompanyInfo.CompanyName(value="99tales GmbH", code="who knows?", code_context="who knows?") + + company_info = Profile.CompanyInfo(company_name=company_name) + + profile = Profile(company_info=company_info, profile_type=ProfileProfileType.VALUE_4) + + profile_info = HotelReservation.ResGlobalInfo.Profiles.ProfileInfo(profile=profile) + + _LOGGER.info(f"Type of profile_info: {type(profile_info)}") + + profiles = HotelReservation.ResGlobalInfo.Profiles(profile_info=profile_info) res_global_info = ( HotelReservation.ResGlobalInfo( hotel_reservation_ids=hotel_res_ids, basic_property_info=basic_property_info, comments=comments_xml, + profiles=profiles, ) ) @@ -917,6 +949,7 @@ def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Rese _LOGGER.error( f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}" ) + _LOGGER.debug(traceback.format_exc()) if type == OtaMessageType.NOTIF: retrieved_reservations = OtaHotelResNotifRq.HotelReservations( diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 68381ea..252368f 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -31,6 +31,7 @@ from datetime import datetime from typing import Dict, Any, Optional, List import json import os +import asyncio import gzip import xml.etree.ElementTree as ET from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version, AlpineBitsActionName @@ -113,13 +114,25 @@ async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel) if request.status_code != 200: _LOGGER.error(f"Failed to generate push request for hotel {hotel_id}, reservation {reservation.unique_id}: {request.xml_content}") return - - print(request.xml_content) - # TODO: Generate AlpineBits OTA_HotelResNotifRQ request - # action = "OTA_HotelResNotifRQ" - # request = server.handle_request(action, ...) - print(f"--- Push Payload --- received. Sending to endpoint., hotelid {hotel_id}, reservation {reservation.unique_id}") + + # save push request to file + + logs_dir = "logs/push_requests" + if not os.path.exists(logs_dir): + os.makedirs(logs_dir, mode=0o755, exist_ok=True) + stat_info = os.stat(logs_dir) + _LOGGER.info( + f"Created directory owner: uid:{stat_info.st_uid}, gid:{stat_info.st_gid}" + ) + _LOGGER.info(f"Directory mode: {oct(stat_info.st_mode)[-3:]}") + log_filename = ( + f"{logs_dir}/alpinebits_push_{hotel_id}_{reservation.unique_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xml" + ) + + + with open(log_filename, "w", encoding="utf-8") as f: + f.write(request.xml_content) return headers = {"Authorization": f"Bearer {push_endpoint.get('token','')}"} if push_endpoint.get('token') else {} @@ -337,7 +350,7 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db name_prefix = data.get("field:anrede") email_newsletter_string = data.get("field:form_field_5a7b", "") - yes_values = {"Selezionato", "Angekreuzt"} + yes_values = {"Selezionato", "Angekreuzt", "Checked"} email_newsletter = (email_newsletter_string in yes_values) address_line = None city_name = None @@ -460,17 +473,21 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db await db.commit() await db.refresh(db_reservation) + + async def push_event(): + # Fire event for listeners (push, etc.) - hotel-specific dispatch + dispatcher = getattr(request.app.state, "event_dispatcher", None) + if dispatcher: + # Get hotel_code from reservation to target the right listeners + hotel_code = getattr(db_reservation, 'hotel_code', None) + if hotel_code and hotel_code.strip(): + await dispatcher.dispatch_for_hotel("form_processed", hotel_code, db_customer, db_reservation) + _LOGGER.info(f"Dispatched form_processed event for hotel {hotel_code}") + else: + _LOGGER.warning("No hotel_code in reservation, skipping push notifications") - # Fire event for listeners (push, etc.) - hotel-specific dispatch - dispatcher = getattr(request.app.state, "event_dispatcher", None) - if dispatcher: - # Get hotel_code from reservation to target the right listeners - hotel_code = getattr(db_reservation, 'hotel_code', None) - if hotel_code and hotel_code.strip(): - await dispatcher.dispatch_for_hotel("form_processed", hotel_code, db_customer, db_reservation) - _LOGGER.info(f"Dispatched form_processed event for hotel {hotel_code}") - else: - _LOGGER.warning("No hotel_code in reservation, skipping push notifications") + asyncio.create_task(push_event()) + return { "status": "success", -- 2.49.1 From 808f0eccc8fcd4c68d0ef16265eda53a83b392e9 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 6 Oct 2025 14:48:16 +0200 Subject: [PATCH 18/18] Added build file --- .github/workflows/build.yaml | 88 ++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..780a1ff --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,88 @@ +name: CI to Docker Hub + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [ "*" ] + tags: [ "*" ] + pull_request: + branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: UV sync + run: uv auth login gitea.linter-home.com --username jonas --password ${{ secrets.CI_TOKEN }} && uv lock + + + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Login to Gitea Docker Registry + uses: docker/login-action@v2 + with: + registry: ${{ vars.REGISTRY }} + username: ${{ vars.USER_NAME }} + password: ${{ secrets.CI_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ vars.REGISTRY }}/${{ vars.USER_NAME }}/asa_api + # generate Docker tags based on the following events/attributes + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + # - name: Debug DNS Resolution + # run: sudo apt-get update && sudo apt-get install -y dnsutils && + # nslookup https://${{ vars.REGISTRY }} + + + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + registry: ${{ vars.REGISTRY }} + username: ${{ vars.USER_NAME }} + password: ${{ secrets.CI_TOKEN }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v6 + with: + context: . + build-args: | + CI_TOKEN=${{ secrets.CI_TOKEN }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} \ No newline at end of file -- 2.49.1