Got db saving working
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Entry point for alpine_bits_python package."""
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -23,13 +23,13 @@ from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
class HttpStatusCode(IntEnum):
|
||||
"""Allowed HTTP status codes for AlpineBits responses."""
|
||||
|
||||
OK = 200
|
||||
BAD_REQUEST = 400
|
||||
UNAUTHORIZED = 401
|
||||
INTERNAL_SERVER_ERROR = 500
|
||||
|
||||
|
||||
|
||||
class AlpineBitsActionName(Enum):
|
||||
"""Enum for AlpineBits action names with capability and request name mappings."""
|
||||
|
||||
@@ -37,27 +37,43 @@ 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 = ("action_OTA_HotelResNotif_GuestRequests",
|
||||
"OTA_HotelResNotif:GuestRequests")
|
||||
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = ("action_OTA_HotelDescriptiveContentNotif_Inventory",
|
||||
"OTA_HotelDescriptiveContentNotif:Inventory")
|
||||
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO = ("action_OTA_HotelDescriptiveContentNotif_Info",
|
||||
"OTA_HotelDescriptiveContentNotif:Info")
|
||||
OTA_HOTEL_DESCRIPTIVE_INFO_INVENTORY = ("action_OTA_HotelDescriptiveInfo_Inventory",
|
||||
"OTA_HotelDescriptiveInfo:Inventory")
|
||||
OTA_HOTEL_DESCRIPTIVE_INFO_INFO = ("action_OTA_HotelDescriptiveInfo_Info",
|
||||
"OTA_HotelDescriptiveInfo:Info")
|
||||
OTA_HOTEL_RATE_PLAN_NOTIF_RATE_PLANS = ("action_OTA_HotelRatePlanNotif_RatePlans",
|
||||
"OTA_HotelRatePlanNotif:RatePlans")
|
||||
OTA_HOTEL_RATE_PLAN_BASE_RATES = ("action_OTA_HotelRatePlan_BaseRates",
|
||||
"OTA_HotelRatePlan:BaseRates")
|
||||
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = (
|
||||
"action_OTA_HotelResNotif_GuestRequests",
|
||||
"OTA_HotelResNotif:GuestRequests",
|
||||
)
|
||||
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
|
||||
"action_OTA_HotelDescriptiveContentNotif_Inventory",
|
||||
"OTA_HotelDescriptiveContentNotif:Inventory",
|
||||
)
|
||||
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO = (
|
||||
"action_OTA_HotelDescriptiveContentNotif_Info",
|
||||
"OTA_HotelDescriptiveContentNotif:Info",
|
||||
)
|
||||
OTA_HOTEL_DESCRIPTIVE_INFO_INVENTORY = (
|
||||
"action_OTA_HotelDescriptiveInfo_Inventory",
|
||||
"OTA_HotelDescriptiveInfo:Inventory",
|
||||
)
|
||||
OTA_HOTEL_DESCRIPTIVE_INFO_INFO = (
|
||||
"action_OTA_HotelDescriptiveInfo_Info",
|
||||
"OTA_HotelDescriptiveInfo:Info",
|
||||
)
|
||||
OTA_HOTEL_RATE_PLAN_NOTIF_RATE_PLANS = (
|
||||
"action_OTA_HotelRatePlanNotif_RatePlans",
|
||||
"OTA_HotelRatePlanNotif:RatePlans",
|
||||
)
|
||||
OTA_HOTEL_RATE_PLAN_BASE_RATES = (
|
||||
"action_OTA_HotelRatePlan_BaseRates",
|
||||
"OTA_HotelRatePlan:BaseRates",
|
||||
)
|
||||
|
||||
def __init__(self, capability_name: str, request_name: str):
|
||||
self.capability_name = capability_name
|
||||
self.request_name = request_name
|
||||
|
||||
@classmethod
|
||||
def get_by_capability_name(cls, capability_name: str) -> Optional['AlpineBitsActionName']:
|
||||
def get_by_capability_name(
|
||||
cls, capability_name: str
|
||||
) -> Optional["AlpineBitsActionName"]:
|
||||
"""Get action enum by capability name."""
|
||||
for action in cls:
|
||||
if action.capability_name == capability_name:
|
||||
@@ -65,7 +81,7 @@ class AlpineBitsActionName(Enum):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_request_name(cls, request_name: str) -> Optional['AlpineBitsActionName']:
|
||||
def get_by_request_name(cls, request_name: str) -> Optional["AlpineBitsActionName"]:
|
||||
"""Get action enum by request name."""
|
||||
for action in cls:
|
||||
if action.request_name == request_name:
|
||||
@@ -75,22 +91,25 @@ class AlpineBitsActionName(Enum):
|
||||
|
||||
class Version(str, Enum):
|
||||
"""Enum for AlpineBits versions."""
|
||||
|
||||
V2024_10 = "2024-10"
|
||||
V2022_10 = "2022-10"
|
||||
# Add other versions as needed
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlpineBitsResponse:
|
||||
"""Response data structure for AlpineBits actions."""
|
||||
|
||||
xml_content: str
|
||||
status_code: HttpStatusCode = HttpStatusCode.OK
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate that status code is one of the allowed values."""
|
||||
if self.status_code not in [200, 400, 401, 500]:
|
||||
raise ValueError(f"Invalid status code {self.status_code}. Must be 200, 400, 401, or 500")
|
||||
raise ValueError(
|
||||
f"Invalid status code {self.status_code}. Must be 200, 400, 401, or 500"
|
||||
)
|
||||
|
||||
|
||||
# Abstract base class for AlpineBits Action
|
||||
@@ -98,9 +117,13 @@ class AlpineBitsAction(ABC):
|
||||
"""Abstract base class for handling AlpineBits actions."""
|
||||
|
||||
name: AlpineBitsActionName
|
||||
version: Version | list[Version] # list of versions in case action supports multiple versions
|
||||
version: (
|
||||
Version | list[Version]
|
||||
) # list of versions in case action supports multiple versions
|
||||
|
||||
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle the incoming request XML and return response XML.
|
||||
|
||||
@@ -132,10 +155,6 @@ class AlpineBitsAction(ABC):
|
||||
return version == self.version
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ServerCapabilities:
|
||||
"""
|
||||
Automatically discovers AlpineBitsAction implementations and generates capabilities.
|
||||
@@ -151,14 +170,15 @@ class ServerCapabilities:
|
||||
current_module = inspect.getmodule(self)
|
||||
|
||||
for name, obj in inspect.getmembers(current_module):
|
||||
if (inspect.isclass(obj) and
|
||||
issubclass(obj, AlpineBitsAction) and
|
||||
obj != AlpineBitsAction):
|
||||
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, AlpineBitsAction)
|
||||
and obj != AlpineBitsAction
|
||||
):
|
||||
# Check if this action is actually implemented (not just returning default)
|
||||
if self._is_action_implemented(obj):
|
||||
action_instance = obj()
|
||||
if hasattr(action_instance, 'name'):
|
||||
if hasattr(action_instance, "name"):
|
||||
# Use capability name for the registry key
|
||||
self.action_registry[action_instance.name.capability_name] = obj
|
||||
|
||||
@@ -168,11 +188,10 @@ class ServerCapabilities:
|
||||
This is a simple check - in practice, you might want more sophisticated detection.
|
||||
"""
|
||||
# Check if the class has overridden the handle method
|
||||
if 'handle' in action_class.__dict__:
|
||||
if "handle" in action_class.__dict__:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_capabilities_dict(self) -> None:
|
||||
"""
|
||||
Generate the capabilities dictionary based on discovered actions.
|
||||
@@ -194,26 +213,20 @@ class ServerCapabilities:
|
||||
version_str = version.value
|
||||
|
||||
if version_str not in versions_dict:
|
||||
versions_dict[version_str] = {
|
||||
"version": version_str,
|
||||
"actions": []
|
||||
}
|
||||
versions_dict[version_str] = {"version": version_str, "actions": []}
|
||||
|
||||
action_dict = {"action": action_name}
|
||||
|
||||
# Add supports field if the action has custom supports
|
||||
if hasattr(action_instance, 'supports') and action_instance.supports:
|
||||
if hasattr(action_instance, "supports") and action_instance.supports:
|
||||
action_dict["supports"] = action_instance.supports
|
||||
|
||||
versions_dict[version_str]["actions"].append(action_dict)
|
||||
|
||||
self.capability_dict = {"versions": list(versions_dict.values())}
|
||||
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_capabilities_dict(self) -> Dict:
|
||||
"""
|
||||
Get capabilities as a dictionary. Generates if not already created.
|
||||
@@ -234,22 +247,35 @@ class ServerCapabilities:
|
||||
|
||||
# Sample Action Implementations for demonstration
|
||||
|
||||
|
||||
class PingAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Ping action (handshaking)."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = AlpineBitsActionName.OTA_PING
|
||||
self.version = [Version.V2024_10, Version.V2022_10] # Supports multiple versions
|
||||
self.version = [
|
||||
Version.V2024_10,
|
||||
Version.V2022_10,
|
||||
] # Supports multiple versions
|
||||
|
||||
async def handle(self, action: str, request_xml: str, version: Version, server_capabilities: None | ServerCapabilities = None) -> AlpineBitsResponse:
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request_xml: str,
|
||||
version: Version,
|
||||
server_capabilities: None | ServerCapabilities = None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle ping requests."""
|
||||
|
||||
if request_xml is None:
|
||||
return AlpineBitsResponse(f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST)
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
if server_capabilities is None:
|
||||
return AlpineBitsResponse("Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR)
|
||||
|
||||
return AlpineBitsResponse(
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# Parse the incoming request XML and extract EchoData
|
||||
parser = XmlParser()
|
||||
@@ -259,7 +285,9 @@ class PingAction(AlpineBitsAction):
|
||||
|
||||
echo_data = json.loads(parsed_request.echo_data)
|
||||
except Exception as e:
|
||||
return AlpineBitsResponse(f"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST)
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# compare echo data with capabilities, create a dictionary containing the matching capabilities
|
||||
capabilities_dict = server_capabilities.get_capabilities_dict()
|
||||
@@ -273,20 +301,25 @@ class PingAction(AlpineBitsAction):
|
||||
for server_version in capabilities_dict["versions"]:
|
||||
if server_version["version"] == client_version_str:
|
||||
# Found a matching version, now find common actions
|
||||
matching_version = {
|
||||
"version": client_version_str,
|
||||
"actions": []
|
||||
}
|
||||
matching_version = {"version": client_version_str, "actions": []}
|
||||
|
||||
# Get client's requested actions for this version
|
||||
client_actions = {action.get("action", ""): action for action in client_version.get("actions", [])}
|
||||
server_actions = {action.get("action", ""): action for action in server_version.get("actions", [])}
|
||||
client_actions = {
|
||||
action.get("action", ""): action
|
||||
for action in client_version.get("actions", [])
|
||||
}
|
||||
server_actions = {
|
||||
action.get("action", ""): action
|
||||
for action in server_version.get("actions", [])
|
||||
}
|
||||
|
||||
# Find common actions
|
||||
for action_name in client_actions:
|
||||
if action_name in server_actions:
|
||||
# Use server's action definition (includes our supports)
|
||||
matching_version["actions"].append(server_actions[action_name])
|
||||
matching_version["actions"].append(
|
||||
server_actions[action_name]
|
||||
)
|
||||
|
||||
# Only add version if there are common actions
|
||||
if matching_version["actions"]:
|
||||
@@ -298,15 +331,20 @@ class PingAction(AlpineBitsAction):
|
||||
# Create successful ping response with matched capabilities
|
||||
capabilities_json = json.dumps(matching_capabilities, indent=2)
|
||||
|
||||
warning = OtaPingRs.Warnings.Warning(status=WarningStatus.ALPINEBITS_HANDSHAKE.value, type_value="11", content=[capabilities_json])
|
||||
warning = OtaPingRs.Warnings.Warning(
|
||||
status=WarningStatus.ALPINEBITS_HANDSHAKE.value,
|
||||
type_value="11",
|
||||
content=[capabilities_json],
|
||||
)
|
||||
|
||||
warning_response = OtaPingRs.Warnings(warning=[warning])
|
||||
|
||||
response_ota_ping = OtaPingRs(version= "7.000", warnings=warning_response, echo_data=capabilities_json, success="")
|
||||
|
||||
|
||||
|
||||
|
||||
response_ota_ping = OtaPingRs(
|
||||
version="7.000",
|
||||
warnings=warning_response,
|
||||
echo_data=capabilities_json,
|
||||
success="",
|
||||
)
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
@@ -314,10 +352,9 @@ class PingAction(AlpineBitsAction):
|
||||
|
||||
serializer = XmlSerializer(config=config)
|
||||
|
||||
response_xml = serializer.render(response_ota_ping, ns_map={None: "http://www.opentravel.org/OTA/2003/05"})
|
||||
|
||||
|
||||
|
||||
response_xml = serializer.render(
|
||||
response_ota_ping, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
@@ -329,13 +366,15 @@ class ReadAction(AlpineBitsAction):
|
||||
self.name = AlpineBitsActionName.OTA_READ
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
|
||||
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle read requests."""
|
||||
response_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
response_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_ReadRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
|
||||
<Success/>
|
||||
<Data>Read operation successful for {version.value}</Data>
|
||||
</OTA_ReadRS>'''
|
||||
</OTA_ReadRS>"""
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
@@ -349,15 +388,17 @@ class HotelAvailNotifAction(AlpineBitsAction):
|
||||
"OTA_HotelAvailNotif_accept_rooms",
|
||||
"OTA_HotelAvailNotif_accept_categories",
|
||||
"OTA_HotelAvailNotif_accept_deltas",
|
||||
"OTA_HotelAvailNotif_accept_BookingThreshold"
|
||||
"OTA_HotelAvailNotif_accept_BookingThreshold",
|
||||
]
|
||||
|
||||
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle hotel availability notifications."""
|
||||
response_xml = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
response_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelAvailNotifRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
|
||||
<Success/>
|
||||
</OTA_HotelAvailNotifRS>'''
|
||||
</OTA_HotelAvailNotifRS>"""
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
@@ -371,10 +412,6 @@ class GuestRequestsAction(AlpineBitsAction):
|
||||
# Note: This class doesn't override the handle method, so it won't be discovered
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class AlpineBitsServer:
|
||||
"""
|
||||
Asynchronous AlpineBits server for handling hotel data exchange requests.
|
||||
@@ -402,7 +439,9 @@ class AlpineBitsServer:
|
||||
"""Get server capabilities as JSON."""
|
||||
return self.capabilities.get_capabilities_json()
|
||||
|
||||
async def handle_request(self, request_action_name: str, request_xml: str, version: str = "2024-10") -> AlpineBitsResponse:
|
||||
async def handle_request(
|
||||
self, request_action_name: str, request_xml: str, version: str = "2024-10"
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
|
||||
@@ -419,8 +458,7 @@ class AlpineBitsServer:
|
||||
version_enum = Version(version)
|
||||
except ValueError:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unsupported version {version}",
|
||||
HttpStatusCode.BAD_REQUEST
|
||||
f"Error: Unsupported version {version}", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# Find the action by request name
|
||||
@@ -428,7 +466,7 @@ class AlpineBitsServer:
|
||||
if not action_enum:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unknown action {request_action_name}",
|
||||
HttpStatusCode.BAD_REQUEST
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if we have an implementation for this action
|
||||
@@ -436,7 +474,7 @@ class AlpineBitsServer:
|
||||
if capability_name not in self._action_instances:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Action {request_action_name} is not implemented",
|
||||
HttpStatusCode.BAD_REQUEST
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
action_instance: AlpineBitsAction = self._action_instances[capability_name]
|
||||
@@ -445,24 +483,29 @@ class AlpineBitsServer:
|
||||
if not await action_instance.check_version_supported(version_enum):
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Action {request_action_name} does not support version {version}",
|
||||
HttpStatusCode.BAD_REQUEST
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Handle the request
|
||||
try:
|
||||
# Special case for ping action - pass server capabilities
|
||||
if capability_name == "action_OTA_Ping":
|
||||
return await action_instance.handle(request_action_name, request_xml, version_enum, self.capabilities)
|
||||
return await action_instance.handle(
|
||||
request_action_name, request_xml, version_enum, self.capabilities
|
||||
)
|
||||
else:
|
||||
return await action_instance.handle(request_action_name, request_xml, version_enum)
|
||||
return await action_instance.handle(
|
||||
request_action_name, request_xml, version_enum
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error handling request {request_action_name}: {str(e)}")
|
||||
# print stack trace for debugging
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Internal server error while processing {request_action_name}: {str(e)}",
|
||||
HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def get_supported_request_names(self) -> List[str]:
|
||||
@@ -474,7 +517,9 @@ class AlpineBitsServer:
|
||||
request_names.append(action_enum.request_name)
|
||||
return sorted(request_names)
|
||||
|
||||
def is_action_supported(self, request_action_name: str, version: str = None) -> bool:
|
||||
def is_action_supported(
|
||||
self, request_action_name: str, version: str = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a request action is supported.
|
||||
|
||||
@@ -524,7 +569,9 @@ async def main():
|
||||
print(f"✅ {capability_name} -> {action_class.__name__}")
|
||||
print(f" Request name: {request_name}")
|
||||
|
||||
print(f"\n📊 Total Implemented Actions: {len(server.capabilities.get_supported_actions())}")
|
||||
print(
|
||||
f"\n📊 Total Implemented Actions: {len(server.capabilities.get_supported_actions())}"
|
||||
)
|
||||
|
||||
print("\n🔍 Generated Capabilities JSON:")
|
||||
print("-" * 30)
|
||||
@@ -548,7 +595,7 @@ async def main():
|
||||
("OTA_Read:GuestRequests", "2022-10"),
|
||||
("OTA_HotelAvailNotif", "2024-10"),
|
||||
("UnknownAction", "2024-10"),
|
||||
("OTA_Ping:Handshaking", "unsupported-version")
|
||||
("OTA_Ping:Handshaking", "unsupported-version"),
|
||||
]
|
||||
|
||||
for request_name, version in test_cases:
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Depends, APIRouter, Form, File, UploadFile
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
BackgroundTasks,
|
||||
Request,
|
||||
Depends,
|
||||
APIRouter,
|
||||
Form,
|
||||
File,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.concurrency import asynccontextmanager
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer, HTTPBasicCredentials, HTTPBasic
|
||||
from .config_loader import load_config
|
||||
@@ -12,7 +23,7 @@ from .rate_limit import (
|
||||
custom_rate_limit_handler,
|
||||
DEFAULT_RATE_LIMIT,
|
||||
WEBHOOK_RATE_LIMIT,
|
||||
BURST_RATE_LIMIT
|
||||
BURST_RATE_LIMIT,
|
||||
)
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
import logging
|
||||
@@ -24,8 +35,14 @@ import gzip
|
||||
import xml.etree.ElementTree as ET
|
||||
from .alpinebits_server import AlpineBitsServer, Version
|
||||
import urllib.parse
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
|
||||
from .db import get_async_session, Customer as DBCustomer, Reservation as DBReservation
|
||||
from .db import (
|
||||
Base,
|
||||
Customer as DBCustomer,
|
||||
Reservation as DBReservation,
|
||||
get_database_url,
|
||||
)
|
||||
|
||||
|
||||
# Configure logging
|
||||
@@ -42,12 +59,36 @@ except Exception as e:
|
||||
_LOGGER.error(f"Failed to load config: {str(e)}")
|
||||
config = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Setup DB
|
||||
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
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_LOGGER.info("Database tables checked/created at startup.")
|
||||
|
||||
yield
|
||||
|
||||
# Optional: Dispose engine on shutdown
|
||||
await engine.dispose()
|
||||
|
||||
async def get_async_session(request: Request):
|
||||
async_sessionmaker = request.app.state.async_sessionmaker
|
||||
async with async_sessionmaker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Wix Form Handler API",
|
||||
description="Secure API endpoint to receive and process Wix form submissions with authentication and rate limiting",
|
||||
version="1.0.0"
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Create API router with /api prefix
|
||||
@@ -64,7 +105,7 @@ app.add_middleware(
|
||||
"https://*.wix.com",
|
||||
"https://*.wixstatic.com",
|
||||
"http://localhost:3000", # For development
|
||||
"http://localhost:8000" # For local testing
|
||||
"http://localhost:8000", # For local testing
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST"],
|
||||
@@ -78,16 +119,26 @@ async def process_form_submission(submission_data: Dict[str, Any]) -> None:
|
||||
Add your business logic here.
|
||||
"""
|
||||
try:
|
||||
_LOGGER.info(f"Processing form submission: {submission_data.get('submissionId')}")
|
||||
_LOGGER.info(
|
||||
f"Processing form submission: {submission_data.get('submissionId')}"
|
||||
)
|
||||
|
||||
# Example processing - you can replace this with your actual logic
|
||||
form_name = submission_data.get('formName')
|
||||
contact_email = submission_data.get('contact', {}).get('email') if submission_data.get('contact') else None
|
||||
form_name = submission_data.get("formName")
|
||||
contact_email = (
|
||||
submission_data.get("contact", {}).get("email")
|
||||
if submission_data.get("contact")
|
||||
else None
|
||||
)
|
||||
|
||||
# Extract form fields
|
||||
form_fields = {k: v for k, v in submission_data.items() if k.startswith('field:')}
|
||||
form_fields = {
|
||||
k: v for k, v in submission_data.items() if k.startswith("field:")
|
||||
}
|
||||
|
||||
_LOGGER.info(f"Form: {form_name}, Contact: {contact_email}, Fields: {len(form_fields)}")
|
||||
_LOGGER.info(
|
||||
f"Form: {form_name}, Contact: {contact_email}, Fields: {len(form_fields)}"
|
||||
)
|
||||
|
||||
# Here you could:
|
||||
# - Save to database
|
||||
@@ -99,6 +150,8 @@ async def process_form_submission(submission_data: Dict[str, Any]) -> None:
|
||||
_LOGGER.error(f"Error processing form submission: {str(e)}")
|
||||
|
||||
|
||||
|
||||
|
||||
@api_router.get("/")
|
||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||
async def root(request: Request):
|
||||
@@ -111,8 +164,8 @@ async def root(request: Request):
|
||||
"rate_limits": {
|
||||
"default": DEFAULT_RATE_LIMIT,
|
||||
"webhook": WEBHOOK_RATE_LIMIT,
|
||||
"burst": BURST_RATE_LIMIT
|
||||
}
|
||||
"burst": BURST_RATE_LIMIT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -126,11 +179,10 @@ async def health_check(request: Request):
|
||||
"service": "wix-form-handler",
|
||||
"version": "1.0.0",
|
||||
"authentication": "enabled",
|
||||
"rate_limiting": "enabled"
|
||||
"rate_limiting": "enabled",
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Extracted business logic for handling Wix form submissions
|
||||
async def process_wix_form_submission(request: Request, data: Dict[str, Any], db):
|
||||
"""
|
||||
@@ -138,7 +190,6 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
"""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
|
||||
_LOGGER.info(f"Received Wix form data at {timestamp}")
|
||||
# _LOGGER.info(f"Data keys: {list(data.keys())}")
|
||||
# _LOGGER.info(f"Full data: {json.dumps(data, indent=2)}")
|
||||
@@ -154,9 +205,13 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
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"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}/wix_test_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
log_filename = (
|
||||
f"{logs_dir}/wix_test_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
)
|
||||
with open(log_filename, "w", encoding="utf-8") as f:
|
||||
json.dump(log_entry, f, indent=2, default=str, ensure_ascii=False)
|
||||
file_stat = os.stat(log_filename)
|
||||
@@ -164,16 +219,10 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
_LOGGER.info(f"File mode: {oct(file_stat.st_mode)[-3:]}")
|
||||
_LOGGER.info(f"Data logged to: {log_filename}")
|
||||
|
||||
|
||||
|
||||
data = data.get("data") # Handle nested "data" key if present
|
||||
|
||||
|
||||
|
||||
|
||||
# save customer and reservation to DB
|
||||
|
||||
|
||||
contact_info = data.get("contact", {})
|
||||
first_name = contact_info.get("name", {}).get("first")
|
||||
last_name = contact_info.get("name", {}).get("last")
|
||||
@@ -193,8 +242,16 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
language = data.get("contact", {}).get("locale", "en")[:2]
|
||||
|
||||
# Dates
|
||||
start_date = data.get("field:date_picker_a7c8") or data.get("Anreisedatum") or data.get("submissions", [{}])[1].get("value")
|
||||
end_date = data.get("field:date_picker_7e65") or data.get("Abreisedatum") or data.get("submissions", [{}])[2].get("value")
|
||||
start_date = (
|
||||
data.get("field:date_picker_a7c8")
|
||||
or data.get("Anreisedatum")
|
||||
or data.get("submissions", [{}])[1].get("value")
|
||||
)
|
||||
end_date = (
|
||||
data.get("field:date_picker_7e65")
|
||||
or data.get("Abreisedatum")
|
||||
or data.get("submissions", [{}])[2].get("value")
|
||||
)
|
||||
|
||||
# Room/guest info
|
||||
num_adults = int(data.get("field:number_7cf5") or 2)
|
||||
@@ -258,7 +315,7 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
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),
|
||||
children_ages=",".join(str(a) for a in children_ages),
|
||||
offer=offer,
|
||||
utm_comment=utm_comment,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
@@ -277,23 +334,21 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
||||
await db.commit()
|
||||
await db.refresh(db_reservation)
|
||||
|
||||
|
||||
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Wix form data received successfully",
|
||||
"received_keys": list(data.keys()),
|
||||
"data_logged_to": log_filename,
|
||||
"timestamp": timestamp,
|
||||
"process_info": log_entry["process_info"],
|
||||
"note": "No authentication required for this endpoint"
|
||||
"note": "No authentication required for this endpoint",
|
||||
}
|
||||
|
||||
|
||||
@api_router.post("/webhook/wix-form")
|
||||
@webhook_limiter.limit(WEBHOOK_RATE_LIMIT)
|
||||
async def handle_wix_form(request: Request, data: Dict[str, Any], db_session=Depends(get_async_session)):
|
||||
async def handle_wix_form(
|
||||
request: Request, data: Dict[str, Any], db_session=Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Unified endpoint to handle Wix form submissions (test and production).
|
||||
No authentication required for this endpoint.
|
||||
@@ -304,16 +359,19 @@ async def handle_wix_form(request: Request, data: Dict[str, Any], db_session=Dep
|
||||
_LOGGER.error(f"Error in handle_wix_form: {str(e)}")
|
||||
# log stacktrace
|
||||
import traceback
|
||||
|
||||
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: {str(e)}"
|
||||
status_code=500, detail=f"Error processing Wix form data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/webhook/wix-form/test")
|
||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||
async def handle_wix_form_test(request: Request, data: Dict[str, Any],db_session=Depends(get_async_session)):
|
||||
async def handle_wix_form_test(
|
||||
request: Request, data: Dict[str, Any], db_session=Depends(get_async_session)
|
||||
):
|
||||
"""
|
||||
Test endpoint to verify the API is working with raw JSON data.
|
||||
No authentication required for testing purposes.
|
||||
@@ -323,26 +381,21 @@ async def handle_wix_form_test(request: Request, data: Dict[str, Any],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: {str(e)}"
|
||||
status_code=500, detail=f"Error processing test data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@api_router.post("/admin/generate-api-key")
|
||||
@limiter.limit("5/hour") # Very restrictive for admin operations
|
||||
async def generate_new_api_key(
|
||||
request: Request,
|
||||
admin_key: str = Depends(validate_api_key)
|
||||
request: Request, admin_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Admin endpoint to generate new API keys.
|
||||
Requires admin API key and is heavily rate limited.
|
||||
"""
|
||||
if admin_key != "admin-key":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required"
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
new_key = generate_api_key()
|
||||
_LOGGER.info(f"Generated new API key (requested by: {admin_key})")
|
||||
@@ -352,11 +405,13 @@ async def generate_new_api_key(
|
||||
"message": "New API key generated",
|
||||
"api_key": new_key,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"note": "Store this key securely - it won't be shown again"
|
||||
"note": "Store this key securely - it won't be shown again",
|
||||
}
|
||||
|
||||
|
||||
async def validate_basic_auth(credentials: HTTPBasicCredentials = Depends(security_basic)) -> str:
|
||||
async def validate_basic_auth(
|
||||
credentials: HTTPBasicCredentials = Depends(security_basic),
|
||||
) -> str:
|
||||
"""
|
||||
Validate basic authentication for AlpineBits protocol.
|
||||
Returns username if valid, raises HTTPException if not.
|
||||
@@ -369,8 +424,11 @@ async def validate_basic_auth(credentials: HTTPBasicCredentials = Depends(securi
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
valid = False
|
||||
for entry in config['alpine_bits_auth']:
|
||||
if credentials.username == entry['username'] and credentials.password == entry['password']:
|
||||
for entry in config["alpine_bits_auth"]:
|
||||
if (
|
||||
credentials.username == entry["username"]
|
||||
and credentials.password == entry["password"]
|
||||
):
|
||||
valid = True
|
||||
break
|
||||
if not valid:
|
||||
@@ -379,7 +437,9 @@ async def validate_basic_auth(credentials: HTTPBasicCredentials = Depends(securi
|
||||
detail="ERROR: Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
_LOGGER.info(f"AlpineBits authentication successful for user: {credentials.username} (from config)")
|
||||
_LOGGER.info(
|
||||
f"AlpineBits authentication successful for user: {credentials.username} (from config)"
|
||||
)
|
||||
return credentials.username
|
||||
|
||||
|
||||
@@ -390,8 +450,7 @@ def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]:
|
||||
"""
|
||||
if "multipart/form-data" not in content_type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data"
|
||||
status_code=400, detail="ERROR: Content-Type must be multipart/form-data"
|
||||
)
|
||||
|
||||
# Extract boundary
|
||||
@@ -404,8 +463,7 @@ def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]:
|
||||
|
||||
if not boundary:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Missing boundary in multipart/form-data"
|
||||
status_code=400, detail="ERROR: Missing boundary in multipart/form-data"
|
||||
)
|
||||
|
||||
# Simple multipart parsing
|
||||
@@ -422,37 +480,32 @@ def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]:
|
||||
content = content.rstrip(b"\r\n")
|
||||
|
||||
# Parse Content-Disposition header
|
||||
headers = headers_section.decode('utf-8', errors='ignore')
|
||||
headers = headers_section.decode("utf-8", errors="ignore")
|
||||
name = None
|
||||
for line in headers.split('\n'):
|
||||
if 'Content-Disposition' in line and 'name=' in line:
|
||||
for line in headers.split("\n"):
|
||||
if "Content-Disposition" in line and "name=" in line:
|
||||
# Extract name parameter
|
||||
for param in line.split(';'):
|
||||
for param in line.split(";"):
|
||||
param = param.strip()
|
||||
if param.startswith('name='):
|
||||
name = param.split('=', 1)[1].strip('"')
|
||||
if param.startswith("name="):
|
||||
name = param.split("=", 1)[1].strip('"')
|
||||
break
|
||||
|
||||
if name:
|
||||
# Handle file uploads or text content
|
||||
if content.startswith(b'<'):
|
||||
if content.startswith(b"<"):
|
||||
# Likely XML content
|
||||
data[name] = content.decode('utf-8', errors='ignore')
|
||||
data[name] = content.decode("utf-8", errors="ignore")
|
||||
else:
|
||||
data[name] = content.decode('utf-8', errors='ignore')
|
||||
data[name] = content.decode("utf-8", errors="ignore")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@api_router.post("/alpinebits/server-2024-10")
|
||||
@limiter.limit("60/minute")
|
||||
async def alpinebits_server_handshake(
|
||||
request: Request,
|
||||
username: str = Depends(validate_basic_auth)
|
||||
request: Request, username: str = Depends(validate_basic_auth)
|
||||
):
|
||||
"""
|
||||
AlpineBits server endpoint implementing the handshake protocol.
|
||||
@@ -471,11 +524,15 @@ async def alpinebits_server_handshake(
|
||||
"""
|
||||
try:
|
||||
# Check required headers
|
||||
client_protocol_version = request.headers.get("X-AlpineBits-ClientProtocolVersion")
|
||||
client_protocol_version = request.headers.get(
|
||||
"X-AlpineBits-ClientProtocolVersion"
|
||||
)
|
||||
if not client_protocol_version:
|
||||
# Server concludes client speaks a protocol version preceding 2013-04
|
||||
client_protocol_version = "pre-2013-04"
|
||||
_LOGGER.info("No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04")
|
||||
_LOGGER.info(
|
||||
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(f"Client protocol version: {client_protocol_version}")
|
||||
|
||||
@@ -503,21 +560,22 @@ async def alpinebits_server_handshake(
|
||||
# Decompress if needed
|
||||
if is_compressed:
|
||||
try:
|
||||
|
||||
body = gzip.decompress(body)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"ERROR: Failed to decompress gzip content: {str(e)}"
|
||||
detail=f"ERROR: Failed to decompress gzip content: {str(e)}",
|
||||
)
|
||||
|
||||
# Check content type (after decompression)
|
||||
if "multipart/form-data" not in content_type and "application/x-www-form-urlencoded" not in content_type:
|
||||
if (
|
||||
"multipart/form-data" not in content_type
|
||||
and "application/x-www-form-urlencoded" not in content_type
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded"
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
# Parse multipart data
|
||||
@@ -527,7 +585,7 @@ async def alpinebits_server_handshake(
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"ERROR: Failed to parse multipart/form-data: {str(e)}"
|
||||
detail=f"ERROR: Failed to parse multipart/form-data: {str(e)}",
|
||||
)
|
||||
elif "application/x-www-form-urlencoded" in content_type:
|
||||
# Parse as urlencoded
|
||||
@@ -535,29 +593,25 @@ async def alpinebits_server_handshake(
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded"
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
# Check for required action parameter
|
||||
action = form_data.get("action")
|
||||
if not action:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Missing required 'action' parameter")
|
||||
status_code=400, detail="ERROR: Missing required 'action' parameter"
|
||||
)
|
||||
|
||||
_LOGGER.info(f"AlpineBits action: {action}")
|
||||
|
||||
|
||||
# Get optional request XML
|
||||
request_xml = form_data.get("request")
|
||||
|
||||
|
||||
server = AlpineBitsServer()
|
||||
|
||||
version = Version.V2024_10
|
||||
|
||||
|
||||
|
||||
# Create successful handshake response
|
||||
response = await server.handle_request(action, request_xml, version)
|
||||
|
||||
@@ -567,42 +621,30 @@ async def alpinebits_server_handshake(
|
||||
headers = {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"X-AlpineBits-Server-Accept-Encoding": "gzip", # Indicate gzip support
|
||||
"X-AlpineBits-Server-Version": "2024-10"
|
||||
"X-AlpineBits-Server-Version": "2024-10",
|
||||
}
|
||||
|
||||
return Response(
|
||||
content=response_xml,
|
||||
status_code=response.status_code,
|
||||
headers=headers
|
||||
content=response_xml, status_code=response.status_code, headers=headers
|
||||
)
|
||||
|
||||
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (auth errors, etc.)
|
||||
raise
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error in AlpineBits handshake: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal server error: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@api_router.get("/admin/stats")
|
||||
@limiter.limit("10/minute")
|
||||
async def get_api_stats(
|
||||
request: Request,
|
||||
admin_key: str = Depends(validate_api_key)
|
||||
):
|
||||
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
|
||||
"""
|
||||
Admin endpoint to get API usage statistics.
|
||||
Requires admin API key.
|
||||
"""
|
||||
if admin_key != "admin-key":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required"
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# In a real application, you'd fetch this from your database/monitoring system
|
||||
return {
|
||||
@@ -611,9 +653,9 @@ async def get_api_stats(
|
||||
"uptime": "Available in production deployment",
|
||||
"total_requests": "Available with monitoring setup",
|
||||
"active_api_keys": len([k for k in ["wix-webhook-key", "admin-key"] if k]),
|
||||
"rate_limit_backend": "redis" if os.getenv("REDIS_URL") else "memory"
|
||||
"rate_limit_backend": "redis" if os.getenv("REDIS_URL") else "memory",
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -629,6 +671,7 @@ async def landing_page():
|
||||
try:
|
||||
# Get the path to the HTML file
|
||||
import os
|
||||
|
||||
html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html")
|
||||
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
@@ -660,4 +703,5 @@ async def landing_page():
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -21,7 +21,7 @@ security = HTTPBearer()
|
||||
API_KEYS = {
|
||||
# Example API keys - replace with your own secure keys
|
||||
"wix-webhook-key": "sk_live_your_secure_api_key_here",
|
||||
"admin-key": "sk_admin_your_admin_key_here"
|
||||
"admin-key": "sk_admin_your_admin_key_here",
|
||||
}
|
||||
|
||||
# Load API keys from environment if available
|
||||
@@ -36,7 +36,9 @@ def generate_api_key() -> str:
|
||||
return f"sk_live_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def validate_api_key(credentials: HTTPAuthorizationCredentials = Security(security)) -> str:
|
||||
def validate_api_key(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
) -> str:
|
||||
"""
|
||||
Validate API key from Authorization header.
|
||||
Expected format: Authorization: Bearer your_api_key_here
|
||||
@@ -67,14 +69,12 @@ def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
|
||||
try:
|
||||
# Remove 'sha256=' prefix if present
|
||||
if signature.startswith('sha256='):
|
||||
if signature.startswith("sha256="):
|
||||
signature = signature[7:]
|
||||
|
||||
# Calculate expected signature
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
secret.encode("utf-8"), payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare signatures securely
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -16,37 +15,45 @@ from annotatedyaml.loader import (
|
||||
from voluptuous import Schema, Required, All, Length, PREVENT_EXTRA, MultipleInvalid
|
||||
|
||||
# --- Voluptuous schemas ---
|
||||
database_schema = Schema({
|
||||
Required('url'): str
|
||||
}, extra=PREVENT_EXTRA)
|
||||
database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA)
|
||||
|
||||
|
||||
|
||||
hotel_auth_schema = Schema({
|
||||
hotel_auth_schema = Schema(
|
||||
{
|
||||
Required("hotel_id"): str,
|
||||
Required("hotel_name"): str,
|
||||
Required("username"): str,
|
||||
Required("password"): str
|
||||
}, extra=PREVENT_EXTRA)
|
||||
|
||||
basic_auth_schema = Schema(
|
||||
All([hotel_auth_schema], Length(min=1))
|
||||
Required("password"): str,
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
config_schema = Schema({
|
||||
Required('database'): database_schema,
|
||||
Required('alpine_bits_auth'): basic_auth_schema
|
||||
}, extra=PREVENT_EXTRA)
|
||||
basic_auth_schema = Schema(All([hotel_auth_schema], Length(min=1)))
|
||||
|
||||
DEFAULT_CONFIG_FILE = 'config.yaml'
|
||||
config_schema = Schema(
|
||||
{
|
||||
Required("database"): database_schema,
|
||||
Required("alpine_bits_auth"): basic_auth_schema,
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
DEFAULT_CONFIG_FILE = "config.yaml"
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, config_folder: str | Path = None, config_name: str = DEFAULT_CONFIG_FILE, testing_mode: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
config_folder: str | Path = None,
|
||||
config_name: str = DEFAULT_CONFIG_FILE,
|
||||
testing_mode: bool = False,
|
||||
):
|
||||
if config_folder is None:
|
||||
config_folder = os.environ.get('ALPINEBITS_CONFIG_DIR')
|
||||
config_folder = os.environ.get("ALPINEBITS_CONFIG_DIR")
|
||||
if not config_folder:
|
||||
config_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../config'))
|
||||
config_folder = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../config")
|
||||
)
|
||||
if isinstance(config_folder, str):
|
||||
config_folder = Path(config_folder)
|
||||
self.config_folder = config_folder
|
||||
@@ -61,8 +68,8 @@ class Config:
|
||||
validated = config_schema(stuff)
|
||||
except MultipleInvalid as e:
|
||||
raise ValueError(f"Config validation error: {e}")
|
||||
self.database = validated['database']
|
||||
self.basic_auth = validated['alpine_bits_auth']
|
||||
self.database = validated["database"]
|
||||
self.basic_auth = validated["alpine_bits_auth"]
|
||||
self.config = validated
|
||||
|
||||
def get(self, key, default=None):
|
||||
@@ -70,19 +77,20 @@ class Config:
|
||||
|
||||
@property
|
||||
def db_url(self) -> str:
|
||||
return self.database['url']
|
||||
return self.database["url"]
|
||||
|
||||
@property
|
||||
def hotel_id(self) -> str:
|
||||
return self.basic_auth['hotel_id']
|
||||
return self.basic_auth["hotel_id"]
|
||||
|
||||
@property
|
||||
def hotel_name(self) -> str:
|
||||
return self.basic_auth['hotel_name']
|
||||
return self.basic_auth["hotel_name"]
|
||||
|
||||
@property
|
||||
def users(self) -> List[Dict[str, str]]:
|
||||
return self.basic_auth['users']
|
||||
return self.basic_auth["users"]
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
def load_config():
|
||||
|
||||
@@ -5,27 +5,24 @@ import os
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Async SQLAlchemy setup
|
||||
def get_database_url(config=None):
|
||||
db_url = None
|
||||
if config and 'database' in config and 'url' in config['database']:
|
||||
db_url = config['database']['url']
|
||||
if config and "database" in config and "url" in config["database"]:
|
||||
db_url = config["database"]["url"]
|
||||
if not db_url:
|
||||
db_url = os.environ.get('DATABASE_URL')
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if not db_url:
|
||||
db_url = 'sqlite+aiosqlite:///alpinebits.db'
|
||||
db_url = "sqlite+aiosqlite:///alpinebits.db"
|
||||
return db_url
|
||||
|
||||
DATABASE_URL = get_database_url()
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def get_async_session():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = 'customers'
|
||||
__tablename__ = "customers"
|
||||
id = Column(Integer, primary_key=True)
|
||||
given_name = Column(String)
|
||||
contact_id = Column(String, unique=True)
|
||||
@@ -43,12 +40,13 @@ class Customer(Base):
|
||||
language = Column(String)
|
||||
address_catalog = Column(Boolean) # Added for XML
|
||||
name_title = Column(String) # Added for XML
|
||||
reservations = relationship('Reservation', back_populates='customer')
|
||||
reservations = relationship("Reservation", back_populates="customer")
|
||||
|
||||
|
||||
class Reservation(Base):
|
||||
__tablename__ = 'reservations'
|
||||
__tablename__ = "reservations"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer, ForeignKey('customers.id'))
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
form_id = Column(String, unique=True)
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
@@ -70,16 +68,14 @@ class Reservation(Base):
|
||||
# Add hotel_code and hotel_name for XML
|
||||
hotel_code = Column(String)
|
||||
hotel_name = Column(String)
|
||||
customer = relationship('Customer', back_populates='reservations')
|
||||
customer = relationship("Customer", back_populates="reservations")
|
||||
|
||||
|
||||
class HashedCustomer(Base):
|
||||
__tablename__ = 'hashed_customers'
|
||||
__tablename__ = "hashed_customers"
|
||||
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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ from .simplified_access import (
|
||||
HotelReservationIdData,
|
||||
PhoneTechType,
|
||||
AlpineBitsFactory,
|
||||
OtaMessageType
|
||||
OtaMessageType,
|
||||
)
|
||||
|
||||
# DB and config
|
||||
from .db import Customer as DBCustomer, Reservation as DBReservation, HashedCustomer, get_async_session
|
||||
from .db import (
|
||||
Customer as DBCustomer,
|
||||
Reservation as DBReservation,
|
||||
HashedCustomer,
|
||||
get_async_session,
|
||||
)
|
||||
from .config_loader import load_config
|
||||
import hashlib
|
||||
import json
|
||||
@@ -29,8 +34,8 @@ import asyncio
|
||||
|
||||
from alpine_bits_python import db
|
||||
|
||||
async def main():
|
||||
|
||||
async def main():
|
||||
print("🚀 Starting AlpineBits XML generation script...")
|
||||
# Load config (yaml, annotatedyaml)
|
||||
config = load_config()
|
||||
@@ -40,9 +45,9 @@ async def main():
|
||||
print(json.dumps(config, indent=2))
|
||||
|
||||
# Ensure SQLite DB file exists if using SQLite
|
||||
db_url = config.get('database', {}).get('url', '')
|
||||
if db_url.startswith('sqlite+aiosqlite:///'):
|
||||
db_path = db_url.replace('sqlite+aiosqlite:///', '')
|
||||
db_url = config.get("database", {}).get("url", "")
|
||||
if db_url.startswith("sqlite+aiosqlite:///"):
|
||||
db_path = db_url.replace("sqlite+aiosqlite:///", "")
|
||||
db_path = os.path.abspath(db_path)
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if not os.path.exists(db_dir):
|
||||
@@ -54,15 +59,17 @@ async def main():
|
||||
|
||||
# # Ensure DB schema is created (async)
|
||||
from .db import engine, Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async for db in get_async_session():
|
||||
|
||||
|
||||
# Load data from JSON file
|
||||
json_path = os.path.join(os.path.dirname(__file__), '../../test_data/wix_test_data_20250928_132611.json')
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../test_data/wix_test_data_20250928_132611.json",
|
||||
)
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
wix_data = json.load(f)
|
||||
data = wix_data["data"]["data"]
|
||||
|
||||
@@ -85,8 +92,16 @@ async def main():
|
||||
language = data.get("contact", {}).get("locale", "en")[:2]
|
||||
|
||||
# Dates
|
||||
start_date = data.get("field:date_picker_a7c8") or data.get("Anreisedatum") or data.get("submissions", [{}])[1].get("value")
|
||||
end_date = data.get("field:date_picker_7e65") or data.get("Abreisedatum") or data.get("submissions", [{}])[2].get("value")
|
||||
start_date = (
|
||||
data.get("field:date_picker_a7c8")
|
||||
or data.get("Anreisedatum")
|
||||
or data.get("submissions", [{}])[1].get("value")
|
||||
)
|
||||
end_date = (
|
||||
data.get("field:date_picker_7e65")
|
||||
or data.get("Abreisedatum")
|
||||
or data.get("submissions", [{}])[2].get("value")
|
||||
)
|
||||
|
||||
# Room/guest info
|
||||
num_adults = int(data.get("field:number_7cf5") or 2)
|
||||
@@ -147,7 +162,7 @@ async def main():
|
||||
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),
|
||||
children_ages=",".join(str(a) for a in children_ages),
|
||||
offer=offer,
|
||||
utm_comment=utm_comment,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
@@ -177,9 +192,19 @@ async def main():
|
||||
|
||||
|
||||
def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
from .simplified_access import CustomerData, GuestCountsFactory, HotelReservationIdData, AlpineBitsFactory, OtaMessageType, CommentData, CommentsData, CommentListItemData
|
||||
from .simplified_access import (
|
||||
CustomerData,
|
||||
GuestCountsFactory,
|
||||
HotelReservationIdData,
|
||||
AlpineBitsFactory,
|
||||
OtaMessageType,
|
||||
CommentData,
|
||||
CommentsData,
|
||||
CommentListItemData,
|
||||
)
|
||||
from .generated import alpinebits as ab
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Prepare data for XML
|
||||
phone_numbers = [(customer.phone, PhoneTechType.MOBILE)] if customer.phone else []
|
||||
customer_data = CustomerData(
|
||||
@@ -200,11 +225,15 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
language=customer.language,
|
||||
)
|
||||
alpine_bits_factory = AlpineBitsFactory()
|
||||
res_guests = alpine_bits_factory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
|
||||
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)
|
||||
guest_counts = GuestCountsFactory.create_retrieve_guest_counts(
|
||||
reservation.num_adults, children_ages
|
||||
)
|
||||
|
||||
# UniqueID
|
||||
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
|
||||
@@ -214,12 +243,14 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
# TimeSpan
|
||||
time_span = ab.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
|
||||
end=reservation.end_date.isoformat() if reservation.end_date else None,
|
||||
)
|
||||
room_stay = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
|
||||
room_stay = (
|
||||
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
|
||||
time_span=time_span,
|
||||
guest_counts=guest_counts,
|
||||
)
|
||||
)
|
||||
room_stays = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
|
||||
room_stay=[room_stay],
|
||||
)
|
||||
@@ -231,7 +262,9 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
res_id_source=None,
|
||||
res_id_source_context="99tales",
|
||||
)
|
||||
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, OtaMessageType.RETRIEVE)
|
||||
hotel_res_id = alpine_bits_factory.create(
|
||||
hotel_res_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
hotel_res_ids = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
|
||||
hotel_reservation_id=[hotel_res_id]
|
||||
)
|
||||
@@ -244,32 +277,38 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
offer_comment = CommentData(
|
||||
name=ab.CommentName2.ADDITIONAL_INFO,
|
||||
text="Angebot/Offerta",
|
||||
list_items=[CommentListItemData(
|
||||
list_items=[
|
||||
CommentListItemData(
|
||||
value=reservation.offer,
|
||||
language=customer.language,
|
||||
list_item="1",
|
||||
)],
|
||||
)
|
||||
],
|
||||
)
|
||||
comment = None
|
||||
if reservation.user_comment:
|
||||
comment = CommentData(
|
||||
name=ab.CommentName2.CUSTOMER_COMMENT,
|
||||
text=reservation.user_comment,
|
||||
list_items=[CommentListItemData(
|
||||
list_items=[
|
||||
CommentListItemData(
|
||||
value="Landing page comment",
|
||||
language=customer.language,
|
||||
list_item="1",
|
||||
)],
|
||||
)
|
||||
],
|
||||
)
|
||||
comments = [offer_comment, comment] if comment else [offer_comment]
|
||||
comments_data = CommentsData(comments=comments)
|
||||
comments_xml = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE)
|
||||
|
||||
res_global_info = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
|
||||
res_global_info = (
|
||||
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
|
||||
hotel_reservation_ids=hotel_res_ids,
|
||||
basic_property_info=basic_property_info,
|
||||
comments=comments_xml,
|
||||
)
|
||||
)
|
||||
|
||||
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
|
||||
create_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
@@ -293,6 +332,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
print("✅ Pydantic validation successful!")
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from xsdata_pydantic.bindings import XmlSerializer
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
)
|
||||
@@ -306,15 +346,18 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
print("\n📄 Generated XML:")
|
||||
print(xml_string)
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
parser = XmlParser()
|
||||
with open("output.xml", "r", encoding="utf-8") as infile:
|
||||
xml_content = infile.read()
|
||||
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
|
||||
print("✅ Round-trip validation successful!")
|
||||
print(f"Parsed reservation status: {parsed_result.reservations_list.hotel_reservation[0].res_status}")
|
||||
print(
|
||||
f"Parsed reservation status: {parsed_result.reservations_list.hotel_reservation[0].res_status}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Validation/Serialization failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@@ -5,18 +5,23 @@ from datetime import datetime
|
||||
|
||||
class AlpineBitsHandshakeRequest(BaseModel):
|
||||
"""Model for AlpineBits handshake request data"""
|
||||
action: str = Field(..., description="Action parameter, typically 'OTA_Ping:Handshaking'")
|
||||
|
||||
action: str = Field(
|
||||
..., description="Action parameter, typically 'OTA_Ping:Handshaking'"
|
||||
)
|
||||
request_xml: Optional[str] = Field(None, description="XML request document")
|
||||
|
||||
|
||||
class ContactName(BaseModel):
|
||||
"""Contact name structure"""
|
||||
|
||||
first: Optional[str] = None
|
||||
last: Optional[str] = None
|
||||
|
||||
|
||||
class ContactAddress(BaseModel):
|
||||
"""Contact address structure"""
|
||||
|
||||
street: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
@@ -26,6 +31,7 @@ class ContactAddress(BaseModel):
|
||||
|
||||
class Contact(BaseModel):
|
||||
"""Contact information from Wix form"""
|
||||
|
||||
name: Optional[ContactName] = None
|
||||
email: Optional[str] = None
|
||||
locale: Optional[str] = None
|
||||
@@ -43,12 +49,14 @@ class Contact(BaseModel):
|
||||
|
||||
class SubmissionPdf(BaseModel):
|
||||
"""PDF submission structure"""
|
||||
|
||||
url: Optional[str] = None
|
||||
filename: Optional[str] = None
|
||||
|
||||
|
||||
class WixFormSubmission(BaseModel):
|
||||
"""Model for Wix form submission data"""
|
||||
|
||||
formName: str
|
||||
submissions: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
submissionTime: str
|
||||
|
||||
@@ -16,6 +16,7 @@ BURST_RATE_LIMIT = "3/second" # Max 3 requests per second per IP
|
||||
# Redis configuration for distributed rate limiting (optional)
|
||||
REDIS_URL = os.getenv("REDIS_URL", None)
|
||||
|
||||
|
||||
def get_remote_address_with_forwarded(request: Request):
|
||||
"""
|
||||
Get client IP address, considering forwarded headers from proxies/load balancers
|
||||
@@ -39,14 +40,16 @@ if REDIS_URL:
|
||||
# Use Redis for distributed rate limiting (recommended for production)
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis_client = redis.from_url(REDIS_URL)
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address_with_forwarded,
|
||||
storage_uri=REDIS_URL
|
||||
key_func=get_remote_address_with_forwarded, storage_uri=REDIS_URL
|
||||
)
|
||||
logger.info("Rate limiting initialized with Redis backend")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to Redis: {e}. Using in-memory rate limiting.")
|
||||
logger.warning(
|
||||
f"Failed to connect to Redis: {e}. Using in-memory rate limiting."
|
||||
)
|
||||
limiter = Limiter(key_func=get_remote_address_with_forwarded)
|
||||
else:
|
||||
# Use in-memory rate limiting (fine for single instance)
|
||||
@@ -77,10 +80,10 @@ def api_key_rate_limit_key(request: Request):
|
||||
|
||||
# Rate limiting decorators for different endpoint types
|
||||
webhook_limiter = Limiter(
|
||||
key_func=api_key_rate_limit_key,
|
||||
storage_uri=REDIS_URL if REDIS_URL else None
|
||||
key_func=api_key_rate_limit_key, storage_uri=REDIS_URL if REDIS_URL else None
|
||||
)
|
||||
|
||||
|
||||
# Custom rate limit exceeded handler
|
||||
def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
"""Custom handler for rate limit exceeded"""
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
|
||||
|
||||
|
||||
def parse_form(form: dict):
|
||||
|
||||
pass
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
"""
|
||||
Startup script for the Wix Form Handler API
|
||||
"""
|
||||
|
||||
import os
|
||||
import uvicorn
|
||||
from .api import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = "alpinebits.db" # Adjust path if needed
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
print(f"Deleted database file: {db_path}")
|
||||
|
||||
uvicorn.run(
|
||||
"alpine_bits_python.api:app",
|
||||
host="0.0.0.0",
|
||||
port=8080,
|
||||
reload=True, # Enable auto-reload during development
|
||||
log_level="info"
|
||||
log_level="info",
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Configuration and setup script for the Wix Form Handler API
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import secrets
|
||||
@@ -11,6 +12,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from alpine_bits_python.auth import generate_api_key
|
||||
|
||||
|
||||
def generate_secure_keys():
|
||||
"""Generate secure API keys for the application"""
|
||||
|
||||
@@ -42,10 +44,12 @@ def generate_secure_keys():
|
||||
|
||||
# Optionally write to .env file
|
||||
create_env = input("\n❓ Create .env file? (y/n): ").lower().strip()
|
||||
if create_env == 'y':
|
||||
if create_env == "y":
|
||||
# Create .env in the project root (two levels up from scripts)
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
|
||||
with open(env_path, 'w') as f:
|
||||
env_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
|
||||
)
|
||||
with open(env_path, "w") as f:
|
||||
f.write(f"WIX_API_KEY={wix_api_key}\n")
|
||||
f.write(f"ADMIN_API_KEY={admin_api_key}\n")
|
||||
f.write(f"WIX_WEBHOOK_SECRET={webhook_secret}\n")
|
||||
@@ -61,9 +65,9 @@ def generate_secure_keys():
|
||||
print("4. Optionally configure webhook signature with the secret above")
|
||||
|
||||
return {
|
||||
'wix_api_key': wix_api_key,
|
||||
'admin_api_key': admin_api_key,
|
||||
'webhook_secret': webhook_secret
|
||||
"wix_api_key": wix_api_key,
|
||||
"admin_api_key": admin_api_key,
|
||||
"webhook_secret": webhook_secret,
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +78,10 @@ def check_security_setup():
|
||||
print("=" * 40)
|
||||
|
||||
# Check environment variables
|
||||
wix_key = os.getenv('WIX_API_KEY')
|
||||
admin_key = os.getenv('ADMIN_API_KEY')
|
||||
webhook_secret = os.getenv('WIX_WEBHOOK_SECRET')
|
||||
redis_url = os.getenv('REDIS_URL')
|
||||
wix_key = os.getenv("WIX_API_KEY")
|
||||
admin_key = os.getenv("ADMIN_API_KEY")
|
||||
webhook_secret = os.getenv("WIX_WEBHOOK_SECRET")
|
||||
redis_url = os.getenv("REDIS_URL")
|
||||
|
||||
print("Environment Variables:")
|
||||
print(f" WIX_API_KEY: {'✅ Set' if wix_key else '❌ Not set'}")
|
||||
@@ -119,7 +123,9 @@ if __name__ == "__main__":
|
||||
print("🔐 Wix Form Handler API - Security Setup")
|
||||
print("=" * 50)
|
||||
|
||||
choice = input("Choose an option:\n1. Generate new API keys\n2. Check current setup\n\nEnter choice (1 or 2): ").strip()
|
||||
choice = input(
|
||||
"Choose an option:\n1. Generate new API keys\n2. Check current setup\n\nEnter choice (1 or 2): "
|
||||
).strip()
|
||||
|
||||
if choice == "1":
|
||||
generate_secure_keys()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Test script for the Secure Wix Form Handler API
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
@@ -30,7 +31,7 @@ SAMPLE_WIX_DATA = {
|
||||
"submissionsLink": "https://www.wix.app/forms/test-form/submissions",
|
||||
"submissionPdf": {
|
||||
"url": "https://example.com/submission.pdf",
|
||||
"filename": "submission.pdf"
|
||||
"filename": "submission.pdf",
|
||||
},
|
||||
"formId": "test-form-789",
|
||||
"field:email_5139": "test@example.com",
|
||||
@@ -43,10 +44,7 @@ SAMPLE_WIX_DATA = {
|
||||
"field:alter_kind_4": "12",
|
||||
"field:long_answer_3524": "This is a long answer field with more details about the inquiry.",
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "John",
|
||||
"last": "Doe"
|
||||
},
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": "test@example.com",
|
||||
"locale": "de",
|
||||
"company": "Test Company",
|
||||
@@ -57,13 +55,13 @@ SAMPLE_WIX_DATA = {
|
||||
"street": "Test Street 123",
|
||||
"city": "Test City",
|
||||
"country": "Germany",
|
||||
"postalCode": "12345"
|
||||
"postalCode": "12345",
|
||||
},
|
||||
"jobTitle": "Manager",
|
||||
"phone": "+1234567890",
|
||||
"createdDate": "2024-03-20T10:00:00.000Z",
|
||||
"updatedDate": "2024-03-20T10:30:00.000Z"
|
||||
}
|
||||
"updatedDate": "2024-03-20T10:30:00.000Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -72,12 +70,12 @@ async def test_api():
|
||||
|
||||
headers_with_auth = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {TEST_API_KEY}"
|
||||
"Authorization": f"Bearer {TEST_API_KEY}",
|
||||
}
|
||||
|
||||
admin_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {ADMIN_API_KEY}"
|
||||
"Authorization": f"Bearer {ADMIN_API_KEY}",
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -105,11 +103,13 @@ async def test_api():
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/webhook/wix-form",
|
||||
json=SAMPLE_WIX_DATA,
|
||||
headers={"Content-Type": "application/json"}
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 401:
|
||||
print(f" ✅ Correctly rejected: {response.status} - {result.get('detail')}")
|
||||
print(
|
||||
f" ✅ Correctly rejected: {response.status} - {result.get('detail')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Unexpected response: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
@@ -121,11 +121,13 @@ async def test_api():
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/webhook/wix-form",
|
||||
json=SAMPLE_WIX_DATA,
|
||||
headers=headers_with_auth
|
||||
headers=headers_with_auth,
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200:
|
||||
print(f" ✅ Webhook success: {response.status} - {result.get('status')}")
|
||||
print(
|
||||
f" ✅ Webhook success: {response.status} - {result.get('status')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Webhook failed: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
@@ -137,11 +139,13 @@ async def test_api():
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/webhook/wix-form/test",
|
||||
json={"test": "data", "timestamp": datetime.now().isoformat()},
|
||||
headers=headers_with_auth
|
||||
headers=headers_with_auth,
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200:
|
||||
print(f" ✅ Test endpoint: {response.status} - {result.get('status')}")
|
||||
print(
|
||||
f" ✅ Test endpoint: {response.status} - {result.get('status')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Test endpoint failed: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
@@ -152,9 +156,7 @@ async def test_api():
|
||||
rate_limit_test_count = 0
|
||||
for i in range(5):
|
||||
try:
|
||||
async with session.get(
|
||||
f"{BASE_URL}/api/health"
|
||||
) as response:
|
||||
async with session.get(f"{BASE_URL}/api/health") as response:
|
||||
if response.status == 200:
|
||||
rate_limit_test_count += 1
|
||||
elif response.status == 429:
|
||||
@@ -171,14 +173,17 @@ async def test_api():
|
||||
print("\n7. Testing admin stats endpoint...")
|
||||
try:
|
||||
async with session.get(
|
||||
f"{BASE_URL}/api/admin/stats",
|
||||
headers=admin_headers
|
||||
f"{BASE_URL}/api/admin/stats", headers=admin_headers
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200:
|
||||
print(f" ✅ Admin stats: {response.status} - {result.get('status')}")
|
||||
print(
|
||||
f" ✅ Admin stats: {response.status} - {result.get('status')}"
|
||||
)
|
||||
elif response.status == 401:
|
||||
print(f" ⚠️ Admin access denied (API key not configured): {result.get('detail')}")
|
||||
print(
|
||||
f" ⚠️ Admin access denied (API key not configured): {result.get('detail')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Admin endpoint failed: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
@@ -189,8 +194,14 @@ if __name__ == "__main__":
|
||||
print("🔒 Testing Secure Wix Form Handler API...")
|
||||
print("=" * 60)
|
||||
print("📍 API URL:", BASE_URL)
|
||||
print("🔑 Using API Key:", TEST_API_KEY[:20] + "..." if len(TEST_API_KEY) > 20 else TEST_API_KEY)
|
||||
print("🔐 Using Admin Key:", ADMIN_API_KEY[:20] + "..." if len(ADMIN_API_KEY) > 20 else ADMIN_API_KEY)
|
||||
print(
|
||||
"🔑 Using API Key:",
|
||||
TEST_API_KEY[:20] + "..." if len(TEST_API_KEY) > 20 else TEST_API_KEY,
|
||||
)
|
||||
print(
|
||||
"🔐 Using Admin Key:",
|
||||
ADMIN_API_KEY[:20] + "..." if len(ADMIN_API_KEY) > 20 else ADMIN_API_KEY,
|
||||
)
|
||||
print("=" * 60)
|
||||
print("Make sure the API is running with: python3 run_api.py")
|
||||
print("-" * 60)
|
||||
|
||||
@@ -15,15 +15,26 @@ NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.
|
||||
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
||||
|
||||
# Define type aliases for Comments types
|
||||
NotifComments = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
|
||||
RetrieveComments = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
|
||||
NotifComment = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
RetrieveComment = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
NotifComments = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
|
||||
)
|
||||
RetrieveComments = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
|
||||
)
|
||||
NotifComment = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
)
|
||||
RetrieveComment = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
)
|
||||
|
||||
# type aliases for GuestCounts
|
||||
NotifGuestCounts = OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
RetrieveGuestCounts = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
|
||||
NotifGuestCounts = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
)
|
||||
RetrieveGuestCounts = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
)
|
||||
|
||||
|
||||
# phonetechtype enum 1,3,5 voice, fax, mobile
|
||||
@@ -42,6 +53,7 @@ class OtaMessageType(Enum):
|
||||
@dataclass
|
||||
class KidsAgeData:
|
||||
"""Data class to hold information about children's ages."""
|
||||
|
||||
ages: list[int]
|
||||
|
||||
|
||||
@@ -77,9 +89,10 @@ class CustomerData:
|
||||
|
||||
|
||||
class GuestCountsFactory:
|
||||
|
||||
@staticmethod
|
||||
def create_notif_guest_counts(adults: int, kids: Optional[list[int]] = None) -> NotifGuestCounts:
|
||||
def create_notif_guest_counts(
|
||||
adults: int, kids: Optional[list[int]] = None
|
||||
) -> NotifGuestCounts:
|
||||
"""
|
||||
Create a GuestCounts object for OtaHotelResNotifRq.
|
||||
:param adults: Number of adults
|
||||
@@ -89,18 +102,23 @@ class GuestCountsFactory:
|
||||
return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts)
|
||||
|
||||
@staticmethod
|
||||
def create_retrieve_guest_counts(adults: int, kids: Optional[list[int]] = None) -> RetrieveGuestCounts:
|
||||
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)
|
||||
|
||||
return GuestCountsFactory._create_guest_counts(
|
||||
adults, kids, RetrieveGuestCounts
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_guest_counts(adults: int, kids: Optional[list[int]], guest_counts_class: type) -> Any:
|
||||
def _create_guest_counts(
|
||||
adults: int, kids: Optional[list[int]], guest_counts_class: type
|
||||
) -> Any:
|
||||
"""
|
||||
Internal method to create a GuestCounts object of the specified type.
|
||||
:param adults: Number of adults
|
||||
@@ -359,6 +377,7 @@ class HotelReservationIdFactory:
|
||||
@dataclass
|
||||
class CommentListItemData:
|
||||
"""Simple data class to hold comment list item information."""
|
||||
|
||||
value: str # The text content of the list item
|
||||
list_item: str # Numeric identifier (pattern: [0-9]+)
|
||||
language: str # Two-letter language code (pattern: [a-z][a-z])
|
||||
@@ -367,6 +386,7 @@ class CommentListItemData:
|
||||
@dataclass
|
||||
class CommentData:
|
||||
"""Simple data class to hold comment information without nested type constraints."""
|
||||
|
||||
name: CommentName2 # Required: "included services", "customer comment", "additional info"
|
||||
text: Optional[str] = None # Optional text content
|
||||
list_items: list[CommentListItemData] = None # Optional list items
|
||||
@@ -379,6 +399,7 @@ class CommentData:
|
||||
@dataclass
|
||||
class CommentsData:
|
||||
"""Simple data class to hold multiple comments (1-3 max)."""
|
||||
|
||||
comments: list[CommentData] = None # 1-3 comments maximum
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -400,7 +421,9 @@ class CommentFactory:
|
||||
return CommentFactory._create_comments(RetrieveComments, RetrieveComment, data)
|
||||
|
||||
@staticmethod
|
||||
def _create_comments(comments_class: type, comment_class: type, data: CommentsData) -> Any:
|
||||
def _create_comments(
|
||||
comments_class: type, comment_class: type, data: CommentsData
|
||||
) -> Any:
|
||||
"""Internal method to create comments of the specified type."""
|
||||
|
||||
comments_list = []
|
||||
@@ -411,15 +434,13 @@ class CommentFactory:
|
||||
list_item = comment_class.ListItem(
|
||||
value=item_data.value,
|
||||
list_item=item_data.list_item,
|
||||
language=item_data.language
|
||||
language=item_data.language,
|
||||
)
|
||||
list_items.append(list_item)
|
||||
|
||||
# Create comment
|
||||
comment = comment_class(
|
||||
name=comment_data.name,
|
||||
text=comment_data.text,
|
||||
list_item=list_items
|
||||
name=comment_data.name, text=comment_data.text, list_item=list_items
|
||||
)
|
||||
comments_list.append(comment)
|
||||
|
||||
@@ -446,17 +467,17 @@ class CommentFactory:
|
||||
list_items_data = []
|
||||
if comment.list_item:
|
||||
for list_item in comment.list_item:
|
||||
list_items_data.append(CommentListItemData(
|
||||
list_items_data.append(
|
||||
CommentListItemData(
|
||||
value=list_item.value,
|
||||
list_item=list_item.list_item,
|
||||
language=list_item.language
|
||||
))
|
||||
language=list_item.language,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract comment data
|
||||
comment_data = CommentData(
|
||||
name=comment.name,
|
||||
text=comment.text,
|
||||
list_items=list_items_data
|
||||
name=comment.name, text=comment.text, list_items=list_items_data
|
||||
)
|
||||
comments_data_list.append(comment_data)
|
||||
|
||||
@@ -531,7 +552,10 @@ class AlpineBitsFactory:
|
||||
"""Unified factory class for creating AlpineBits objects with a simple interface."""
|
||||
|
||||
@staticmethod
|
||||
def create(data: Union[CustomerData, HotelReservationIdData, CommentsData], message_type: OtaMessageType) -> Any:
|
||||
def create(
|
||||
data: Union[CustomerData, HotelReservationIdData, CommentsData],
|
||||
message_type: OtaMessageType,
|
||||
) -> Any:
|
||||
"""
|
||||
Create an AlpineBits object based on the data type and message type.
|
||||
|
||||
@@ -552,7 +576,9 @@ class AlpineBitsFactory:
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
||||
else:
|
||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
|
||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
data
|
||||
)
|
||||
|
||||
elif isinstance(data, CommentsData):
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
@@ -564,7 +590,9 @@ class AlpineBitsFactory:
|
||||
raise ValueError(f"Unsupported data type: {type(data)}")
|
||||
|
||||
@staticmethod
|
||||
def create_res_guests(customer_data: CustomerData, message_type: OtaMessageType) -> Union[NotifResGuests, RetrieveResGuests]:
|
||||
def create_res_guests(
|
||||
customer_data: CustomerData, message_type: OtaMessageType
|
||||
) -> Union[NotifResGuests, RetrieveResGuests]:
|
||||
"""
|
||||
Create a complete ResGuests structure with a primary customer.
|
||||
|
||||
@@ -581,7 +609,9 @@ class AlpineBitsFactory:
|
||||
return ResGuestFactory.create_retrieve_res_guests(customer_data)
|
||||
|
||||
@staticmethod
|
||||
def extract_data(obj: Any) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
|
||||
def extract_data(
|
||||
obj: Any,
|
||||
) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
|
||||
"""
|
||||
Extract data from an AlpineBits object back to a simple data class.
|
||||
|
||||
@@ -592,28 +622,28 @@ class AlpineBitsFactory:
|
||||
The appropriate data object
|
||||
"""
|
||||
# Check if it's a Customer object
|
||||
if hasattr(obj, 'person_name') and hasattr(obj.person_name, 'given_name'):
|
||||
if hasattr(obj, "person_name") and hasattr(obj.person_name, "given_name"):
|
||||
if isinstance(obj, NotifCustomer):
|
||||
return CustomerFactory.from_notif_customer(obj)
|
||||
elif isinstance(obj, RetrieveCustomer):
|
||||
return CustomerFactory.from_retrieve_customer(obj)
|
||||
|
||||
# Check if it's a HotelReservationId object
|
||||
elif hasattr(obj, 'res_id_type'):
|
||||
elif hasattr(obj, "res_id_type"):
|
||||
if isinstance(obj, NotifHotelReservationId):
|
||||
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
|
||||
elif isinstance(obj, RetrieveHotelReservationId):
|
||||
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
|
||||
|
||||
# Check if it's a Comments object
|
||||
elif hasattr(obj, 'comment'):
|
||||
elif hasattr(obj, "comment"):
|
||||
if isinstance(obj, NotifComments):
|
||||
return CommentFactory.from_notif_comments(obj)
|
||||
elif isinstance(obj, RetrieveComments):
|
||||
return CommentFactory.from_retrieve_comments(obj)
|
||||
|
||||
# Check if it's a ResGuests object
|
||||
elif hasattr(obj, 'res_guest'):
|
||||
elif hasattr(obj, "res_guest"):
|
||||
return ResGuestFactory.extract_primary_customer(obj)
|
||||
|
||||
else:
|
||||
@@ -744,16 +774,17 @@ if __name__ == "__main__":
|
||||
|
||||
print("=== HotelReservationId Creation ===")
|
||||
reservation_id_data = HotelReservationIdData(
|
||||
res_id_type="123",
|
||||
res_id_value="RESERVATION-456",
|
||||
res_id_source="HOTEL_SYSTEM"
|
||||
res_id_type="123", res_id_value="RESERVATION-456", res_id_source="HOTEL_SYSTEM"
|
||||
)
|
||||
notif_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.RETRIEVE)
|
||||
retrieve_res_id = AlpineBitsFactory.create(
|
||||
reservation_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
print("Created reservation IDs using unified factory")
|
||||
|
||||
print("=== Comments Creation ===")
|
||||
comments_data = CommentsData(comments=[
|
||||
comments_data = CommentsData(
|
||||
comments=[
|
||||
CommentData(
|
||||
name=CommentName2.CUSTOMER_COMMENT,
|
||||
text="This is a customer comment about the reservation",
|
||||
@@ -761,27 +792,30 @@ if __name__ == "__main__":
|
||||
CommentListItemData(
|
||||
value="Special dietary requirements: vegetarian",
|
||||
list_item="1",
|
||||
language="en"
|
||||
language="en",
|
||||
),
|
||||
CommentListItemData(
|
||||
value="Late arrival expected",
|
||||
list_item="2",
|
||||
language="en"
|
||||
)
|
||||
]
|
||||
value="Late arrival expected", list_item="2", language="en"
|
||||
),
|
||||
],
|
||||
),
|
||||
CommentData(
|
||||
name=CommentName2.ADDITIONAL_INFO,
|
||||
text="Additional information about the stay"
|
||||
text="Additional information about the stay",
|
||||
),
|
||||
]
|
||||
)
|
||||
])
|
||||
notif_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.NOTIF)
|
||||
retrieve_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.RETRIEVE)
|
||||
print("Created comments using unified factory")
|
||||
|
||||
print("=== ResGuests Creation ===")
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
print("Created ResGuests using unified factory")
|
||||
|
||||
print("=== Data Extraction ===")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Entry point for util package."""
|
||||
|
||||
from .handshake_util import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,26 +2,22 @@ from ..generated.alpinebits import OtaPingRq, OtaPingRs
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
# test parsing a ping request sample
|
||||
|
||||
path = "AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
|
||||
path = (
|
||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
|
||||
)
|
||||
|
||||
with open(
|
||||
path, "r", encoding="utf-8") as f:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
xml = f.read()
|
||||
|
||||
# Parse the XML into the request object
|
||||
|
||||
# Test parsing back
|
||||
|
||||
|
||||
parser = XmlParser()
|
||||
|
||||
|
||||
|
||||
parsed_result = parser.from_string(xml, OtaPingRs)
|
||||
|
||||
print(parsed_result.echo_data)
|
||||
@@ -34,19 +30,14 @@ def main():
|
||||
|
||||
print(warning.content[0])
|
||||
|
||||
|
||||
|
||||
|
||||
# save json in echo_data to file with indents
|
||||
output_path = "echo_data_response.json"
|
||||
with open(output_path, "w", encoding="utf-8") as out_f:
|
||||
import json
|
||||
|
||||
json.dump(json.loads(parsed_result.echo_data), out_f, indent=4)
|
||||
print(f"Saved echo_data json to {output_path}")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
|
||||
main()
|
||||
@@ -2,11 +2,12 @@
|
||||
"""
|
||||
Convenience launcher for the Wix Form Handler API
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Change to src directory
|
||||
src_dir = os.path.join(os.path.dirname(__file__), 'src/alpine_bits_python')
|
||||
src_dir = os.path.join(os.path.dirname(__file__), "src/alpine_bits_python")
|
||||
|
||||
# Run the API using uv
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -10,10 +10,11 @@ from alpine_bits_python.alpinebits_server import (
|
||||
AlpineBitsActionName,
|
||||
Version,
|
||||
AlpineBitsResponse,
|
||||
HttpStatusCode
|
||||
HttpStatusCode,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
|
||||
class NewImplementedAction(AlpineBitsAction):
|
||||
"""A new action that IS implemented."""
|
||||
|
||||
@@ -21,10 +22,13 @@ class NewImplementedAction(AlpineBitsAction):
|
||||
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:
|
||||
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)."""
|
||||
|
||||
@@ -34,6 +38,7 @@ class NewUnimplementedAction(AlpineBitsAction):
|
||||
|
||||
# Notice: No handle method override - will use default "not implemented"
|
||||
|
||||
|
||||
async def main():
|
||||
print("🔍 Testing Action Discovery Logic")
|
||||
print("=" * 50)
|
||||
@@ -57,5 +62,6 @@ async def main():
|
||||
result = await unimplemented_action.handle("test", "<xml/>", Version.V2024_10)
|
||||
print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -4,7 +4,7 @@ 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'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from simplified_access import (
|
||||
CustomerData,
|
||||
@@ -20,7 +20,7 @@ from simplified_access import (
|
||||
NotifResGuests,
|
||||
RetrieveResGuests,
|
||||
NotifHotelReservationId,
|
||||
RetrieveHotelReservationId
|
||||
RetrieveHotelReservationId,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def sample_customer_data():
|
||||
phone_numbers=[
|
||||
("+1234567890", PhoneTechType.MOBILE),
|
||||
("+0987654321", PhoneTechType.VOICE),
|
||||
("+1111111111", None)
|
||||
("+1111111111", None),
|
||||
],
|
||||
email_address="john.doe@example.com",
|
||||
email_newsletter=True,
|
||||
@@ -46,17 +46,14 @@ def sample_customer_data():
|
||||
address_catalog=False,
|
||||
gender="Male",
|
||||
birth_date="1980-01-01",
|
||||
language="en"
|
||||
language="en",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_customer_data():
|
||||
"""Fixture providing minimal customer data (only required fields)."""
|
||||
return CustomerData(
|
||||
given_name="Jane",
|
||||
surname="Smith"
|
||||
)
|
||||
return CustomerData(given_name="Jane", surname="Smith")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -66,16 +63,14 @@ def sample_hotel_reservation_id_data():
|
||||
res_id_type="123",
|
||||
res_id_value="RESERVATION-456",
|
||||
res_id_source="HOTEL_SYSTEM",
|
||||
res_id_source_context="BOOKING_ENGINE"
|
||||
res_id_source_context="BOOKING_ENGINE",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_hotel_reservation_id_data():
|
||||
"""Fixture providing minimal hotel reservation ID data (only required fields)."""
|
||||
return HotelReservationIdData(
|
||||
res_id_type="999"
|
||||
)
|
||||
return HotelReservationIdData(res_id_type="999")
|
||||
|
||||
|
||||
class TestCustomerData:
|
||||
@@ -152,7 +147,9 @@ class TestCustomerFactory:
|
||||
def test_create_customer_minimal(self, minimal_customer_data):
|
||||
"""Test creating customers with minimal data."""
|
||||
notif_customer = CustomerFactory.create_notif_customer(minimal_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(minimal_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(
|
||||
minimal_customer_data
|
||||
)
|
||||
|
||||
for customer in [notif_customer, retrieve_customer]:
|
||||
assert customer.person_name.given_name == "Jane"
|
||||
@@ -169,40 +166,64 @@ class TestCustomerFactory:
|
||||
def test_email_newsletter_options(self):
|
||||
"""Test different email newsletter options."""
|
||||
# Newsletter yes
|
||||
data_yes = CustomerData(given_name="Test", surname="User",
|
||||
email_address="test@example.com", email_newsletter=True)
|
||||
data_yes = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
email_address="test@example.com",
|
||||
email_newsletter=True,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_yes)
|
||||
assert customer.email.remark == "newsletter:yes"
|
||||
|
||||
# Newsletter no
|
||||
data_no = CustomerData(given_name="Test", surname="User",
|
||||
email_address="test@example.com", email_newsletter=False)
|
||||
data_no = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
email_address="test@example.com",
|
||||
email_newsletter=False,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_no)
|
||||
assert customer.email.remark == "newsletter:no"
|
||||
|
||||
# Newsletter not specified
|
||||
data_none = CustomerData(given_name="Test", surname="User",
|
||||
email_address="test@example.com", email_newsletter=None)
|
||||
data_none = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
email_address="test@example.com",
|
||||
email_newsletter=None,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_none)
|
||||
assert customer.email.remark is None
|
||||
|
||||
def test_address_catalog_options(self):
|
||||
"""Test different address catalog options."""
|
||||
# Catalog no
|
||||
data_no = CustomerData(given_name="Test", surname="User",
|
||||
address_line="123 Street", address_catalog=False)
|
||||
data_no = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
address_line="123 Street",
|
||||
address_catalog=False,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_no)
|
||||
assert customer.address.remark == "catalog:no"
|
||||
|
||||
# Catalog yes
|
||||
data_yes = CustomerData(given_name="Test", surname="User",
|
||||
address_line="123 Street", address_catalog=True)
|
||||
data_yes = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
address_line="123 Street",
|
||||
address_catalog=True,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_yes)
|
||||
assert customer.address.remark == "catalog:yes"
|
||||
|
||||
# Catalog not specified
|
||||
data_none = CustomerData(given_name="Test", surname="User",
|
||||
address_line="123 Street", address_catalog=None)
|
||||
data_none = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
address_line="123 Street",
|
||||
address_catalog=None,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_none)
|
||||
assert customer.address.remark is None
|
||||
|
||||
@@ -228,8 +249,8 @@ class TestCustomerFactory:
|
||||
phone_numbers=[
|
||||
("+1111111111", PhoneTechType.VOICE),
|
||||
("+2222222222", PhoneTechType.FAX),
|
||||
("+3333333333", PhoneTechType.MOBILE)
|
||||
]
|
||||
("+3333333333", PhoneTechType.MOBILE),
|
||||
],
|
||||
)
|
||||
|
||||
customer = CustomerFactory.create_notif_customer(data)
|
||||
@@ -241,14 +262,20 @@ class TestCustomerFactory:
|
||||
class TestHotelReservationIdData:
|
||||
"""Test the HotelReservationIdData dataclass."""
|
||||
|
||||
def test_hotel_reservation_id_data_creation_full(self, sample_hotel_reservation_id_data):
|
||||
def test_hotel_reservation_id_data_creation_full(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating HotelReservationIdData with all fields."""
|
||||
assert sample_hotel_reservation_id_data.res_id_type == "123"
|
||||
assert sample_hotel_reservation_id_data.res_id_value == "RESERVATION-456"
|
||||
assert sample_hotel_reservation_id_data.res_id_source == "HOTEL_SYSTEM"
|
||||
assert sample_hotel_reservation_id_data.res_id_source_context == "BOOKING_ENGINE"
|
||||
assert (
|
||||
sample_hotel_reservation_id_data.res_id_source_context == "BOOKING_ENGINE"
|
||||
)
|
||||
|
||||
def test_hotel_reservation_id_data_creation_minimal(self, minimal_hotel_reservation_id_data):
|
||||
def test_hotel_reservation_id_data_creation_minimal(
|
||||
self, minimal_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating HotelReservationIdData with only required fields."""
|
||||
assert minimal_hotel_reservation_id_data.res_id_type == "999"
|
||||
assert minimal_hotel_reservation_id_data.res_id_value is None
|
||||
@@ -259,9 +286,13 @@ class TestHotelReservationIdData:
|
||||
class TestHotelReservationIdFactory:
|
||||
"""Test the HotelReservationIdFactory class."""
|
||||
|
||||
def test_create_notif_hotel_reservation_id_full(self, sample_hotel_reservation_id_data):
|
||||
def test_create_notif_hotel_reservation_id_full(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating a NotifHotelReservationId with full data."""
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
|
||||
assert isinstance(reservation_id, NotifHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
@@ -269,9 +300,13 @@ class TestHotelReservationIdFactory:
|
||||
assert reservation_id.res_id_source == "HOTEL_SYSTEM"
|
||||
assert reservation_id.res_id_source_context == "BOOKING_ENGINE"
|
||||
|
||||
def test_create_retrieve_hotel_reservation_id_full(self, sample_hotel_reservation_id_data):
|
||||
def test_create_retrieve_hotel_reservation_id_full(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating a RetrieveHotelReservationId with full data."""
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
|
||||
assert isinstance(reservation_id, RetrieveHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
@@ -279,10 +314,20 @@ class TestHotelReservationIdFactory:
|
||||
assert reservation_id.res_id_source == "HOTEL_SYSTEM"
|
||||
assert reservation_id.res_id_source_context == "BOOKING_ENGINE"
|
||||
|
||||
def test_create_hotel_reservation_id_minimal(self, minimal_hotel_reservation_id_data):
|
||||
def test_create_hotel_reservation_id_minimal(
|
||||
self, minimal_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating hotel reservation IDs with minimal data."""
|
||||
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(minimal_hotel_reservation_id_data)
|
||||
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(minimal_hotel_reservation_id_data)
|
||||
notif_reservation_id = (
|
||||
HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
minimal_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
retrieve_reservation_id = (
|
||||
HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
minimal_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
|
||||
for reservation_id in [notif_reservation_id, retrieve_reservation_id]:
|
||||
assert reservation_id.res_id_type == "999"
|
||||
@@ -290,17 +335,29 @@ class TestHotelReservationIdFactory:
|
||||
assert reservation_id.res_id_source is None
|
||||
assert reservation_id.res_id_source_context is None
|
||||
|
||||
def test_from_notif_hotel_reservation_id_roundtrip(self, sample_hotel_reservation_id_data):
|
||||
def test_from_notif_hotel_reservation_id_roundtrip(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test converting NotifHotelReservationId back to HotelReservationIdData."""
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
converted_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(reservation_id)
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
converted_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(
|
||||
reservation_id
|
||||
)
|
||||
|
||||
assert converted_data == sample_hotel_reservation_id_data
|
||||
|
||||
def test_from_retrieve_hotel_reservation_id_roundtrip(self, sample_hotel_reservation_id_data):
|
||||
def test_from_retrieve_hotel_reservation_id_roundtrip(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test converting RetrieveHotelReservationId back to HotelReservationIdData."""
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
converted_data = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(reservation_id)
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
converted_data = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(
|
||||
reservation_id
|
||||
)
|
||||
|
||||
assert converted_data == sample_hotel_reservation_id_data
|
||||
|
||||
@@ -334,8 +391,12 @@ class TestResGuestFactory:
|
||||
|
||||
def test_create_res_guests_minimal(self, minimal_customer_data):
|
||||
"""Test creating ResGuests with minimal customer data."""
|
||||
notif_res_guests = ResGuestFactory.create_notif_res_guests(minimal_customer_data)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(minimal_customer_data)
|
||||
notif_res_guests = ResGuestFactory.create_notif_res_guests(
|
||||
minimal_customer_data
|
||||
)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(
|
||||
minimal_customer_data
|
||||
)
|
||||
|
||||
for res_guests in [notif_res_guests, retrieve_res_guests]:
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
@@ -395,35 +456,47 @@ class TestAlpineBitsFactory:
|
||||
|
||||
def test_create_customer_retrieve(self, sample_customer_data):
|
||||
"""Test creating customer using unified factory for RETRIEVE."""
|
||||
customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
customer = AlpineBitsFactory.create(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
assert isinstance(customer, RetrieveCustomer)
|
||||
assert customer.person_name.given_name == "John"
|
||||
assert customer.person_name.surname == "Doe"
|
||||
|
||||
def test_create_hotel_reservation_id_notif(self, sample_hotel_reservation_id_data):
|
||||
"""Test creating hotel reservation ID using unified factory for NOTIF."""
|
||||
reservation_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.NOTIF)
|
||||
reservation_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.NOTIF
|
||||
)
|
||||
assert isinstance(reservation_id, NotifHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
assert reservation_id.res_id_value == "RESERVATION-456"
|
||||
|
||||
def test_create_hotel_reservation_id_retrieve(self, sample_hotel_reservation_id_data):
|
||||
def test_create_hotel_reservation_id_retrieve(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating hotel reservation ID using unified factory for RETRIEVE."""
|
||||
reservation_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE)
|
||||
reservation_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
assert isinstance(reservation_id, RetrieveHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
assert reservation_id.res_id_value == "RESERVATION-456"
|
||||
|
||||
def test_create_res_guests_notif(self, sample_customer_data):
|
||||
"""Test creating ResGuests using unified factory for NOTIF."""
|
||||
res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.NOTIF)
|
||||
res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
assert isinstance(res_guests, NotifResGuests)
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
assert customer.person_name.given_name == "John"
|
||||
|
||||
def test_create_res_guests_retrieve(self, sample_customer_data):
|
||||
"""Test creating ResGuests using unified factory for RETRIEVE."""
|
||||
res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
assert isinstance(res_guests, RetrieveResGuests)
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
assert customer.person_name.given_name == "John"
|
||||
@@ -431,8 +504,12 @@ class TestAlpineBitsFactory:
|
||||
def test_extract_data_from_customer(self, sample_customer_data):
|
||||
"""Test extracting data from customer objects."""
|
||||
# Create both types and extract data back
|
||||
notif_customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.NOTIF)
|
||||
retrieve_customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
notif_customer = AlpineBitsFactory.create(
|
||||
sample_customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_customer = AlpineBitsFactory.create(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
notif_extracted = AlpineBitsFactory.extract_data(notif_customer)
|
||||
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_customer)
|
||||
@@ -440,11 +517,17 @@ class TestAlpineBitsFactory:
|
||||
assert notif_extracted == sample_customer_data
|
||||
assert retrieve_extracted == sample_customer_data
|
||||
|
||||
def test_extract_data_from_hotel_reservation_id(self, sample_hotel_reservation_id_data):
|
||||
def test_extract_data_from_hotel_reservation_id(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test extracting data from hotel reservation ID objects."""
|
||||
# Create both types and extract data back
|
||||
notif_res_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE)
|
||||
notif_res_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_res_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
notif_extracted = AlpineBitsFactory.extract_data(notif_res_id)
|
||||
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_res_id)
|
||||
@@ -455,8 +538,12 @@ class TestAlpineBitsFactory:
|
||||
def test_extract_data_from_res_guests(self, sample_customer_data):
|
||||
"""Test extracting data from ResGuests objects."""
|
||||
# Create both types and extract data back
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
notif_extracted = AlpineBitsFactory.extract_data(notif_res_guests)
|
||||
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_res_guests)
|
||||
@@ -481,33 +568,46 @@ class TestAlpineBitsFactory:
|
||||
given_name="Unified",
|
||||
surname="Factory",
|
||||
email_address="unified@factory.com",
|
||||
phone_numbers=[("+1234567890", PhoneTechType.MOBILE)]
|
||||
phone_numbers=[("+1234567890", PhoneTechType.MOBILE)],
|
||||
)
|
||||
|
||||
reservation_data = HotelReservationIdData(
|
||||
res_id_type="999",
|
||||
res_id_value="UNIFIED-TEST"
|
||||
res_id_type="999", res_id_value="UNIFIED-TEST"
|
||||
)
|
||||
|
||||
# Create using unified factory
|
||||
customer_notif = AlpineBitsFactory.create(customer_data, OtaMessageType.NOTIF)
|
||||
customer_retrieve = AlpineBitsFactory.create(customer_data, OtaMessageType.RETRIEVE)
|
||||
customer_retrieve = AlpineBitsFactory.create(
|
||||
customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
res_id_notif = AlpineBitsFactory.create(reservation_data, OtaMessageType.NOTIF)
|
||||
res_id_retrieve = AlpineBitsFactory.create(reservation_data, OtaMessageType.RETRIEVE)
|
||||
res_id_retrieve = AlpineBitsFactory.create(
|
||||
reservation_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
res_guests_notif = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
|
||||
res_guests_retrieve = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
|
||||
res_guests_notif = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
res_guests_retrieve = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
# Extract everything back
|
||||
extracted_customer_from_notif = AlpineBitsFactory.extract_data(customer_notif)
|
||||
extracted_customer_from_retrieve = AlpineBitsFactory.extract_data(customer_retrieve)
|
||||
extracted_customer_from_retrieve = AlpineBitsFactory.extract_data(
|
||||
customer_retrieve
|
||||
)
|
||||
|
||||
extracted_res_id_from_notif = AlpineBitsFactory.extract_data(res_id_notif)
|
||||
extracted_res_id_from_retrieve = AlpineBitsFactory.extract_data(res_id_retrieve)
|
||||
|
||||
extracted_from_res_guests_notif = AlpineBitsFactory.extract_data(res_guests_notif)
|
||||
extracted_from_res_guests_retrieve = AlpineBitsFactory.extract_data(res_guests_retrieve)
|
||||
extracted_from_res_guests_notif = AlpineBitsFactory.extract_data(
|
||||
res_guests_notif
|
||||
)
|
||||
extracted_from_res_guests_retrieve = AlpineBitsFactory.extract_data(
|
||||
res_guests_retrieve
|
||||
)
|
||||
|
||||
# Verify everything matches
|
||||
assert extracted_customer_from_notif == customer_data
|
||||
@@ -525,31 +625,66 @@ class TestIntegration:
|
||||
"""Test that both factories can work with the same customer data."""
|
||||
# Create using CustomerFactory
|
||||
notif_customer = CustomerFactory.create_notif_customer(sample_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(sample_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(
|
||||
sample_customer_data
|
||||
)
|
||||
|
||||
# Create using ResGuestFactory and extract customers
|
||||
notif_res_guests = ResGuestFactory.create_notif_res_guests(sample_customer_data)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(sample_customer_data)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(
|
||||
sample_customer_data
|
||||
)
|
||||
|
||||
notif_from_res_guests = notif_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
retrieve_from_res_guests = retrieve_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
notif_from_res_guests = (
|
||||
notif_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
)
|
||||
retrieve_from_res_guests = (
|
||||
retrieve_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
)
|
||||
|
||||
# Compare customer names (structure should be identical)
|
||||
assert notif_customer.person_name.given_name == notif_from_res_guests.person_name.given_name
|
||||
assert notif_customer.person_name.surname == notif_from_res_guests.person_name.surname
|
||||
assert retrieve_customer.person_name.given_name == retrieve_from_res_guests.person_name.given_name
|
||||
assert retrieve_customer.person_name.surname == retrieve_from_res_guests.person_name.surname
|
||||
assert (
|
||||
notif_customer.person_name.given_name
|
||||
== notif_from_res_guests.person_name.given_name
|
||||
)
|
||||
assert (
|
||||
notif_customer.person_name.surname
|
||||
== notif_from_res_guests.person_name.surname
|
||||
)
|
||||
assert (
|
||||
retrieve_customer.person_name.given_name
|
||||
== retrieve_from_res_guests.person_name.given_name
|
||||
)
|
||||
assert (
|
||||
retrieve_customer.person_name.surname
|
||||
== retrieve_from_res_guests.person_name.surname
|
||||
)
|
||||
|
||||
def test_hotel_reservation_id_factories_produce_same_data(self, sample_hotel_reservation_id_data):
|
||||
def test_hotel_reservation_id_factories_produce_same_data(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test that both HotelReservationId factories produce equivalent results."""
|
||||
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
notif_reservation_id = (
|
||||
HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
retrieve_reservation_id = (
|
||||
HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
|
||||
# Both should have the same field values
|
||||
assert notif_reservation_id.res_id_type == retrieve_reservation_id.res_id_type
|
||||
assert notif_reservation_id.res_id_value == retrieve_reservation_id.res_id_value
|
||||
assert notif_reservation_id.res_id_source == retrieve_reservation_id.res_id_source
|
||||
assert notif_reservation_id.res_id_source_context == retrieve_reservation_id.res_id_source_context
|
||||
assert (
|
||||
notif_reservation_id.res_id_source == retrieve_reservation_id.res_id_source
|
||||
)
|
||||
assert (
|
||||
notif_reservation_id.res_id_source_context
|
||||
== retrieve_reservation_id.res_id_source_context
|
||||
)
|
||||
|
||||
def test_complex_customer_workflow(self):
|
||||
"""Test a complex workflow with multiple operations."""
|
||||
@@ -559,7 +694,7 @@ class TestIntegration:
|
||||
surname="Johnson",
|
||||
phone_numbers=[
|
||||
("+1555123456", PhoneTechType.MOBILE),
|
||||
("+1555654321", PhoneTechType.VOICE)
|
||||
("+1555654321", PhoneTechType.VOICE),
|
||||
],
|
||||
email_address="alice.johnson@company.com",
|
||||
email_newsletter=False,
|
||||
@@ -569,7 +704,7 @@ class TestIntegration:
|
||||
country_code="CA",
|
||||
address_catalog=True,
|
||||
gender="Female",
|
||||
language="fr"
|
||||
language="fr",
|
||||
)
|
||||
|
||||
# Create ResGuests for both types
|
||||
@@ -578,7 +713,9 @@ class TestIntegration:
|
||||
|
||||
# Extract data back from both
|
||||
notif_extracted = ResGuestFactory.extract_primary_customer(notif_res_guests)
|
||||
retrieve_extracted = ResGuestFactory.extract_primary_customer(retrieve_res_guests)
|
||||
retrieve_extracted = ResGuestFactory.extract_primary_customer(
|
||||
retrieve_res_guests
|
||||
)
|
||||
|
||||
# All should be equal
|
||||
assert original_data == notif_extracted
|
||||
@@ -592,16 +729,28 @@ class TestIntegration:
|
||||
res_id_type="456",
|
||||
res_id_value="COMPLEX-RESERVATION-789",
|
||||
res_id_source="INTEGRATION_SYSTEM",
|
||||
res_id_source_context="API_CALL"
|
||||
res_id_source_context="API_CALL",
|
||||
)
|
||||
|
||||
# Create HotelReservationId for both types
|
||||
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(original_data)
|
||||
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(original_data)
|
||||
notif_reservation_id = (
|
||||
HotelReservationIdFactory.create_notif_hotel_reservation_id(original_data)
|
||||
)
|
||||
retrieve_reservation_id = (
|
||||
HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
original_data
|
||||
)
|
||||
)
|
||||
|
||||
# Extract data back from both
|
||||
notif_extracted = HotelReservationIdFactory.from_notif_hotel_reservation_id(notif_reservation_id)
|
||||
retrieve_extracted = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(retrieve_reservation_id)
|
||||
notif_extracted = HotelReservationIdFactory.from_notif_hotel_reservation_id(
|
||||
notif_reservation_id
|
||||
)
|
||||
retrieve_extracted = (
|
||||
HotelReservationIdFactory.from_retrieve_hotel_reservation_id(
|
||||
retrieve_reservation_id
|
||||
)
|
||||
)
|
||||
|
||||
# All should be equal
|
||||
assert original_data == notif_extracted
|
||||
|
||||
@@ -6,6 +6,7 @@ Test the handshake functionality with the real AlpineBits sample file.
|
||||
import asyncio
|
||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer
|
||||
|
||||
|
||||
async def main():
|
||||
print("🔄 Testing AlpineBits Handshake with Sample File")
|
||||
print("=" * 60)
|
||||
@@ -14,16 +15,22 @@ async def main():
|
||||
server = AlpineBitsServer()
|
||||
|
||||
# Read the sample handshake request
|
||||
with open("AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml", "r") as f:
|
||||
with open(
|
||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml",
|
||||
"r",
|
||||
) as f:
|
||||
ping_request_xml = f.read()
|
||||
|
||||
print("📤 Sending handshake request...")
|
||||
|
||||
# Handle the ping request
|
||||
response = await server.handle_request("OTA_Ping:Handshaking", ping_request_xml, "2024-10")
|
||||
response = await server.handle_request(
|
||||
"OTA_Ping:Handshaking", ping_request_xml, "2024-10"
|
||||
)
|
||||
|
||||
print(f"\n📥 Response Status: {response.status_code}")
|
||||
print(f"📄 Response XML:\n{response.xml_content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user