Formatting
This commit is contained in:
@@ -53,17 +53,21 @@ RetrieveGuestCounts = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
)
|
||||
|
||||
NotifUniqueId = (OtaHotelResNotifRq.HotelReservations.HotelReservation.UniqueId)
|
||||
RetrieveUniqueId = (OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId)
|
||||
NotifUniqueId = OtaHotelResNotifRq.HotelReservations.HotelReservation.UniqueId
|
||||
RetrieveUniqueId = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId
|
||||
|
||||
NotifTimeSpan = (OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.TimeSpan)
|
||||
RetrieveTimeSpan = (OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan)
|
||||
NotifTimeSpan = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.TimeSpan
|
||||
)
|
||||
RetrieveTimeSpan = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan
|
||||
)
|
||||
|
||||
NotifRoomStays = (OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays)
|
||||
RetrieveRoomStays = (OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays)
|
||||
NotifRoomStays = OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays
|
||||
RetrieveRoomStays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays
|
||||
|
||||
NotifHotelReservation = (OtaHotelResNotifRq.HotelReservations.HotelReservation)
|
||||
RetrieveHotelReservation = (OtaResRetrieveRs.ReservationsList.HotelReservation)
|
||||
NotifHotelReservation = OtaHotelResNotifRq.HotelReservations.HotelReservation
|
||||
RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation
|
||||
|
||||
|
||||
# phonetechtype enum 1,3,5 voice, fax, mobile
|
||||
@@ -119,10 +123,13 @@ class CustomerData:
|
||||
|
||||
class GuestCountsFactory:
|
||||
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||
|
||||
@staticmethod
|
||||
def create_guest_counts(
|
||||
adults: int, kids: Optional[list[int]] = None
|
||||
, message_type: OtaMessageType = OtaMessageType.RETRIEVE) -> NotifGuestCounts:
|
||||
adults: int,
|
||||
kids: Optional[list[int]] = None,
|
||||
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
|
||||
) -> NotifGuestCounts:
|
||||
"""
|
||||
Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
|
||||
:param adults: Number of adults
|
||||
@@ -130,14 +137,16 @@ class GuestCountsFactory:
|
||||
:return: GuestCounts instance
|
||||
"""
|
||||
if message_type == OtaMessageType.RETRIEVE:
|
||||
return GuestCountsFactory._create_guest_counts(adults, kids, RetrieveGuestCounts)
|
||||
return GuestCountsFactory._create_guest_counts(
|
||||
adults, kids, RetrieveGuestCounts
|
||||
)
|
||||
elif message_type == OtaMessageType.NOTIF:
|
||||
return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts)
|
||||
return GuestCountsFactory._create_guest_counts(
|
||||
adults, kids, NotifGuestCounts
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported message type: {message_type}")
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _create_guest_counts(
|
||||
adults: int, kids: Optional[list[int]], guest_counts_class: type
|
||||
@@ -575,9 +584,6 @@ class ResGuestFactory:
|
||||
return CustomerFactory.from_notif_customer(customer)
|
||||
else:
|
||||
return CustomerFactory.from_retrieve_customer(customer)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class AlpineBitsFactory:
|
||||
@@ -680,25 +686,25 @@ class AlpineBitsFactory:
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||
|
||||
def create_res_retrieve_response(list: list[Tuple[Reservation, Customer]]):
|
||||
|
||||
|
||||
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 _process_single_reservation(reservation: Reservation, customer: Customer, message_type: OtaMessageType):
|
||||
|
||||
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.phone, PhoneTechType.MOBILE)] if customer.phone is not None else []
|
||||
)
|
||||
|
||||
customer_data = CustomerData(
|
||||
@@ -719,9 +725,7 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
language=customer.language,
|
||||
)
|
||||
alpine_bits_factory = AlpineBitsFactory()
|
||||
res_guests = alpine_bits_factory.create_res_guests(
|
||||
customer_data, message_type
|
||||
)
|
||||
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]
|
||||
@@ -731,8 +735,6 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
|
||||
unique_id_string = reservation.unique_id
|
||||
|
||||
|
||||
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
UniqueId = NotifUniqueId
|
||||
RoomStays = NotifRoomStays
|
||||
@@ -747,24 +749,16 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
raise ValueError(f"Unsupported message type: {message_type}")
|
||||
|
||||
# UniqueID
|
||||
unique_id = UniqueId(
|
||||
type_value=UniqueIdType2.VALUE_14, id=unique_id_string
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
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_stay = RoomStays.RoomStay(
|
||||
time_span=time_span,
|
||||
guest_counts=guest_counts,
|
||||
)
|
||||
room_stays = RoomStays(
|
||||
room_stay=[room_stay],
|
||||
@@ -779,11 +773,10 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
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
|
||||
else: # extract string from Column object
|
||||
klick_id = str(klick_id)
|
||||
|
||||
hotel_res_id_data = HotelReservationIdData(
|
||||
@@ -796,17 +789,16 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
# 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
|
||||
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
|
||||
# shorten klick_id if longer than 64 characters
|
||||
if klick_id is not None and len(klick_id) > 64:
|
||||
klick_id = klick_id[:64]
|
||||
|
||||
@@ -820,9 +812,7 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
res_id_source_context="99tales",
|
||||
)
|
||||
|
||||
hotel_res_id = alpine_bits_factory.create(
|
||||
hotel_res_id_data, message_type
|
||||
)
|
||||
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]
|
||||
)
|
||||
@@ -881,16 +871,17 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
)
|
||||
|
||||
comments_data = CommentsData(comments=comments)
|
||||
comments_xml = alpine_bits_factory.create(
|
||||
comments_data, message_type
|
||||
)
|
||||
|
||||
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_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 = Profile(
|
||||
company_info=company_info, profile_type=ProfileProfileType.VALUE_4
|
||||
)
|
||||
|
||||
profile_info = HotelReservation.ResGlobalInfo.Profiles.ProfileInfo(profile=profile)
|
||||
|
||||
@@ -898,13 +889,11 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
|
||||
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,
|
||||
)
|
||||
res_global_info = HotelReservation.ResGlobalInfo(
|
||||
hotel_reservation_ids=hotel_res_ids,
|
||||
basic_property_info=basic_property_info,
|
||||
comments=comments_xml,
|
||||
profiles=profiles,
|
||||
)
|
||||
|
||||
hotel_reservation = HotelReservation(
|
||||
@@ -920,7 +909,10 @@ def _process_single_reservation(reservation: Reservation, customer: Customer, me
|
||||
return hotel_reservation
|
||||
|
||||
|
||||
def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Reservation, Customer], type: OtaMessageType):
|
||||
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)
|
||||
@@ -933,14 +925,12 @@ def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Rese
|
||||
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:
|
||||
|
||||
hotel_reservation = _process_single_reservation(reservation, customer, type)
|
||||
|
||||
reservations_list.append(hotel_reservation)
|
||||
@@ -968,7 +958,6 @@ def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Rese
|
||||
|
||||
return ota_hotel_res_notif_rq
|
||||
elif type == OtaMessageType.RETRIEVE:
|
||||
|
||||
retrieved_reservations = OtaResRetrieveRs.ReservationsList(
|
||||
hotel_reservation=reservations_list
|
||||
)
|
||||
@@ -984,7 +973,7 @@ def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Rese
|
||||
raise
|
||||
|
||||
return ota_res_retrieve_rs
|
||||
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported message type: {type}")
|
||||
|
||||
|
||||
@@ -18,10 +18,21 @@ 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_notif_push_message, 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
|
||||
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
|
||||
@@ -61,12 +72,12 @@ 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 = ( ## Push Action for 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
|
||||
"action_OTA_Read", # if read is supported this is also supported
|
||||
"OTA_NotifReport:GuestRequests",
|
||||
)
|
||||
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
|
||||
@@ -268,7 +279,6 @@ 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()
|
||||
@@ -283,7 +293,8 @@ class ServerCapabilities:
|
||||
for version in self.capability_dict["versions"]:
|
||||
if version["version"] == "2024-10":
|
||||
version["actions"] = [
|
||||
action for action in version["actions"]
|
||||
action
|
||||
for action in version["actions"]
|
||||
if action.get("action") != "action_OTA_Ping"
|
||||
]
|
||||
|
||||
@@ -298,7 +309,6 @@ class ServerCapabilities:
|
||||
self.create_capabilities_dict()
|
||||
return self.capability_dict
|
||||
|
||||
|
||||
def get_supported_actions(self) -> List[str]:
|
||||
"""Get list of all supported action names."""
|
||||
return list(self.action_registry.keys())
|
||||
@@ -395,7 +405,6 @@ class PingAction(AlpineBitsAction):
|
||||
# Create successful ping response with matched capabilities
|
||||
capabilities_json_str = dump_json_for_xml(matching_capabilities)
|
||||
|
||||
|
||||
warning = OtaPingRs.Warnings.Warning(
|
||||
status=WarningStatus.ALPINEBITS_HANDSHAKE,
|
||||
type_value="11",
|
||||
@@ -404,8 +413,6 @@ class PingAction(AlpineBitsAction):
|
||||
|
||||
warning_response = OtaPingRs.Warnings(warning=[warning])
|
||||
|
||||
|
||||
|
||||
client_response_echo_data = dump_json_for_xml(echo_data_client)
|
||||
|
||||
response_ota_ping = OtaPingRs(
|
||||
@@ -510,7 +517,9 @@ class ReadAction(AlpineBitsAction):
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not validate_hotel_authentication(client_info.username, client_info.password, hotelid, self.config):
|
||||
if not validate_hotel_authentication(
|
||||
client_info.username, client_info.password, hotelid, self.config
|
||||
):
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
@@ -525,8 +534,6 @@ 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)
|
||||
@@ -547,8 +554,6 @@ class ReadAction(AlpineBitsAction):
|
||||
)
|
||||
stmt = stmt.filter(~Reservation.id.in_(subquery))
|
||||
|
||||
|
||||
|
||||
result = await dbsession.execute(stmt)
|
||||
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
|
||||
result.all()
|
||||
@@ -601,9 +606,7 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
warnings = notif_report.warnings
|
||||
notif_report_details = notif_report.notif_details
|
||||
|
||||
success_message = OtaNotifReportRs(
|
||||
version="7.000", success=""
|
||||
)
|
||||
success_message = OtaNotifReportRs(version="7.000", success="")
|
||||
|
||||
if client_info.client_id is None:
|
||||
return AlpineBitsResponse(
|
||||
@@ -622,12 +625,14 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
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:
|
||||
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:
|
||||
|
||||
if dbsession is None:
|
||||
return AlpineBitsResponse(
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
@@ -635,20 +640,18 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
return AlpineBitsResponse(
|
||||
response_xml, HttpStatusCode.OK
|
||||
)
|
||||
|
||||
|
||||
class PushAction(AlpineBitsAction):
|
||||
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests"""
|
||||
@@ -671,7 +674,6 @@ class PushAction(AlpineBitsAction):
|
||||
|
||||
xml_push_request = create_res_notif_push_message(request_xml)
|
||||
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
)
|
||||
@@ -682,8 +684,6 @@ class PushAction(AlpineBitsAction):
|
||||
|
||||
return AlpineBitsResponse(xml_push_request, HttpStatusCode.OK)
|
||||
|
||||
|
||||
|
||||
|
||||
class AlpineBitsServer:
|
||||
"""
|
||||
@@ -740,7 +740,9 @@ 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}")
|
||||
_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}",
|
||||
@@ -748,7 +750,7 @@ class AlpineBitsServer:
|
||||
)
|
||||
|
||||
# Check if we have an implementation for this action
|
||||
|
||||
|
||||
if action_enum not in self._action_instances:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Action {request_action_name} is not implemented",
|
||||
@@ -769,7 +771,6 @@ class AlpineBitsServer:
|
||||
# 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(
|
||||
@@ -777,16 +778,21 @@ class AlpineBitsServer:
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
action=request_action_name, request_xml=request_xml, version=version_enum, client_info=client_info
|
||||
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
|
||||
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,
|
||||
@@ -848,5 +854,3 @@ class AlpineBitsServer:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,12 @@ 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 generate_unique_id, 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,
|
||||
@@ -34,7 +39,12 @@ import os
|
||||
import asyncio
|
||||
import gzip
|
||||
import xml.etree.ElementTree as ET
|
||||
from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version, AlpineBitsActionName
|
||||
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
|
||||
@@ -57,29 +67,31 @@ security_basic = HTTPBasic()
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
# --- 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()
|
||||
|
||||
# Load config at startup
|
||||
@@ -92,30 +104,41 @@ async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel)
|
||||
"""
|
||||
push_endpoint = hotel.get("push_endpoint")
|
||||
if not push_endpoint:
|
||||
_LOGGER.warning(f"No push endpoint configured for hotel {hotel.get('hotel_id')}")
|
||||
_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']
|
||||
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}")
|
||||
_LOGGER.warning(
|
||||
f"Hotel ID mismatch: listener for {hotel_id}, reservation for {reservation_hotel_id}"
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.info(f"Processing push notification for hotel {hotel_id}, reservation {reservation.unique_id}")
|
||||
|
||||
_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)
|
||||
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}")
|
||||
_LOGGER.error(
|
||||
f"Failed to generate push request for hotel {hotel_id}, reservation {reservation.unique_id}: {request.xml_content}"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# save push request to file
|
||||
|
||||
logs_dir = "logs/push_requests"
|
||||
@@ -126,28 +149,37 @@ async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel)
|
||||
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"
|
||||
)
|
||||
|
||||
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 {}
|
||||
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}")
|
||||
|
||||
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}")
|
||||
|
||||
_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}")
|
||||
# Optionally implement retry logic here@asynccontextmanager
|
||||
|
||||
|
||||
async def lifespan(app: FastAPI):
|
||||
# Setup DB
|
||||
|
||||
@@ -167,20 +199,19 @@ 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_endpoint = hotel.get("push_endpoint")
|
||||
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)
|
||||
"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')}"
|
||||
)
|
||||
_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:
|
||||
@@ -351,7 +382,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", "Checked"}
|
||||
email_newsletter = (email_newsletter_string in yes_values)
|
||||
email_newsletter = email_newsletter_string in yes_values
|
||||
address_line = None
|
||||
city_name = None
|
||||
postal_code = None
|
||||
@@ -397,15 +428,13 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
]
|
||||
|
||||
# get submissionId and ensure max length 35. Generate one if not present
|
||||
|
||||
|
||||
unique_id = data.get("submissionId", generate_unique_id())
|
||||
|
||||
if len(unique_id) > 32:
|
||||
# strip to first 35 chars
|
||||
unique_id = unique_id[:32]
|
||||
|
||||
|
||||
|
||||
# use database session
|
||||
|
||||
# Save all relevant data to DB (including new fields)
|
||||
@@ -429,23 +458,22 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
)
|
||||
db.add(db_customer)
|
||||
await db.flush() # This assigns db_customer.id without committing
|
||||
#await db.refresh(db_customer)
|
||||
|
||||
# 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
|
||||
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
|
||||
data.get("field:hotelname")
|
||||
or data.get("hotelname")
|
||||
or request.app.state.config.get("default_hotel_name")
|
||||
or "Frangart Inn" # fallback
|
||||
)
|
||||
|
||||
db_reservation = DBReservation(
|
||||
@@ -472,22 +500,24 @@ 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)
|
||||
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
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")
|
||||
_LOGGER.warning(
|
||||
"No hotel_code in reservation, skipping push notifications"
|
||||
)
|
||||
|
||||
asyncio.create_task(push_event())
|
||||
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -517,9 +547,7 @@ async def handle_wix_form(
|
||||
|
||||
traceback_str = traceback.format_exc()
|
||||
_LOGGER.error(f"Stack trace for handle_wix_form: {traceback_str}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error processing Wix form data"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Error processing Wix form data")
|
||||
|
||||
|
||||
@api_router.post("/webhook/wix-form/test")
|
||||
@@ -535,9 +563,7 @@ async def handle_wix_form_test(
|
||||
return await process_wix_form_submission(request, data, db_session)
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error in handle_wix_form_test: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error processing test data"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Error processing test data")
|
||||
|
||||
|
||||
@api_router.post("/admin/generate-api-key")
|
||||
@@ -773,7 +799,9 @@ async def alpinebits_server_handshake(
|
||||
|
||||
username, password = credentials_tupel
|
||||
|
||||
client_info = AlpineBitsClientInfo(username=username, password=password, client_id=client_id)
|
||||
client_info = AlpineBitsClientInfo(
|
||||
username=username, password=password, client_id=client_id
|
||||
)
|
||||
|
||||
# Create successful handshake response
|
||||
response = await server.handle_request(
|
||||
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
|
||||
@@ -44,7 +44,7 @@ class Reservation(Base):
|
||||
__tablename__ = "reservations"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
unique_id = Column(String(35), unique=True) # max length 35
|
||||
unique_id = Column(String(35), unique=True) # max length 35
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
num_adults = Column(Integer)
|
||||
@@ -67,11 +67,12 @@ class Reservation(Base):
|
||||
customer = relationship("Customer", back_populates="reservations")
|
||||
|
||||
|
||||
|
||||
# Table for tracking acknowledged requests by client
|
||||
class AckedRequest(Base):
|
||||
__tablename__ = 'acked_requests'
|
||||
__tablename__ = "acked_requests"
|
||||
id = Column(Integer, primary_key=True)
|
||||
client_id = Column(String, index=True)
|
||||
unique_id = Column(String, index=True) # Should match Reservation.form_id or another unique field
|
||||
unique_id = Column(
|
||||
String, index=True
|
||||
) # Should match Reservation.form_id or another unique field
|
||||
timestamp = Column(DateTime)
|
||||
|
||||
@@ -4,7 +4,6 @@ import sys
|
||||
import os
|
||||
|
||||
|
||||
|
||||
from alpine_bits_python.alpine_bits_helpers import (
|
||||
CustomerData,
|
||||
CustomerFactory,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
|
||||
|
||||
|
||||
|
||||
import pytest
|
||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer, AlpineBitsClientInfo
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
@@ -9,14 +5,3 @@ from alpine_bits_python.generated.alpinebits import OtaResRetrieveRs, OtaHotelRe
|
||||
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import asyncio
|
||||
@@ -8,8 +7,6 @@ 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
|
||||
@@ -17,21 +14,25 @@ def extract_relevant_sections(xml_string):
|
||||
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:
|
||||
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:
|
||||
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"
|
||||
version="2024-10",
|
||||
)
|
||||
actual_obj = extract_relevant_sections(response.xml_content)
|
||||
expected_obj = extract_relevant_sections(expected_xml)
|
||||
@@ -40,12 +41,17 @@ async def test_ping_action_response_matches_expected():
|
||||
|
||||
expected_matches = json.loads(expected_obj.warnings.warning[0].content[0])
|
||||
|
||||
assert actual_matches == expected_matches, f"Expected warnings {expected_matches}, got {actual_matches}"
|
||||
assert actual_matches == expected_matches, (
|
||||
f"Expected warnings {expected_matches}, got {actual_matches}"
|
||||
)
|
||||
|
||||
actual_capabilities = json.loads(actual_obj.echo_data)
|
||||
expected_capabilities = json.loads(expected_obj.echo_data)
|
||||
|
||||
assert actual_capabilities == expected_capabilities, f"Expected echo data {expected_capabilities}, got {actual_capabilities}"
|
||||
assert actual_capabilities == expected_capabilities, (
|
||||
f"Expected echo data {expected_capabilities}, got {actual_capabilities}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_success():
|
||||
@@ -57,13 +63,14 @@ async def test_ping_action_response_success():
|
||||
request_action_name="OTA_Ping:Handshaking",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10"
|
||||
version="2024-10",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "<OTA_PingRS" in response.xml_content
|
||||
assert "<Success" in response.xml_content
|
||||
assert "Version=" in response.xml_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_version_arbitrary():
|
||||
server = AlpineBitsServer()
|
||||
@@ -74,12 +81,13 @@ async def test_ping_action_response_version_arbitrary():
|
||||
request_action_name="OTA_Ping:Handshaking",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2022-10"
|
||||
version="2022-10",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "<OTA_PingRS" in response.xml_content
|
||||
assert "Version=" in response.xml_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_invalid_action():
|
||||
server = AlpineBitsServer()
|
||||
@@ -90,7 +98,7 @@ async def test_ping_action_response_invalid_action():
|
||||
request_action_name="InvalidAction",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10"
|
||||
version="2024-10",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Error" in response.xml_content
|
||||
|
||||
Reference in New Issue
Block a user