Compare commits
3 Commits
f0945ed431
...
5ec47b8332
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec47b8332 | ||
|
|
122c7c8be4 | ||
|
|
6102194712 |
60
.vscode/settings.json
vendored
60
.vscode/settings.json
vendored
@@ -1,8 +1,56 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"notebook.formatOnSave.enabled": true,
|
||||
"notebook.codeActionsOnSave": {
|
||||
// "notebook.source.fixAll": "explicit",
|
||||
// "notebook.source.organizeImports": "explicit"
|
||||
},
|
||||
"notebook.output.wordWrap": true,
|
||||
"notebook.output.textLineLimit": 200,
|
||||
"jupyter.debugJustMyCode": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"files.exclude": {
|
||||
"**/*.egg-info": true,
|
||||
"**/htmlcov": true,
|
||||
"**/~$*": true,
|
||||
"**/.coverage.*": true,
|
||||
"**/.venv": true,
|
||||
"**/__pycache__": true,
|
||||
"**/.mypy_cache": true,
|
||||
"**/.pytest_cache": true,
|
||||
}
|
||||
}
|
||||
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Debug Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"purpose": [
|
||||
"debug-test"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTEST_ADDOPTS": "--no-cov"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
99Tales_Testexport.xml
Normal file
24
99Tales_Testexport.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<reservations>
|
||||
<reservation id="2409" number="191" date="2025-08-28" creationTime="2025-08-28T11:53:45" type="reservation" bookingGroup="" bookingChannel="99TALES" advertisingMedium="99TALES" advertisingPartner="399">
|
||||
<guest id="364" lastName="Busch" firstName="Sebastian" language="de" gender="male" dateOfBirth="" postalCode="58454" city="Witten" countryCode="DE" country="DEUTSCHLAND" email="test@test.com"/>
|
||||
<company/>
|
||||
<roomReservations>
|
||||
<roomReservation arrival="2025-09-03" departure="2025-09-12" status="reserved" roomType="EZ" roomNumber="106" adults="1" children="0" infants="0" ratePlanCode="WEEK" connectedRoomType="0">
|
||||
<connectedRooms/>
|
||||
<dailySales>
|
||||
<dailySale date="2025-09-03" revenueTotal="174" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="26.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-04" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-05" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-06" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-07" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-08" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-09" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-10" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-11" revenueTotal="149" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="1.5" revenueResources=""/>
|
||||
<dailySale date="2025-09-12" revenueTotal="" revenueLogis="" revenueBoard="" revenueFB="" revenueSpa="" revenueOther="" revenueResources=""/>
|
||||
</dailySales>
|
||||
</roomReservation>
|
||||
</roomReservations>
|
||||
</reservation>
|
||||
</reservations>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T09:38:38.167778+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="3"/>
|
||||
<GuestCount Count="1" Age="12"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Genesia</GivenName>
|
||||
<Surname>Supino</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -599,9 +599,7 @@ class AlpineBitsFactory:
|
||||
if isinstance(data, HotelReservationIdData):
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
data
|
||||
)
|
||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
|
||||
|
||||
if isinstance(data, CommentsData):
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
@@ -668,6 +666,7 @@ class AlpineBitsFactory:
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||
return None
|
||||
|
||||
|
||||
def create_res_retrieve_response(list: list[tuple[Reservation, Customer]]):
|
||||
@@ -726,7 +725,7 @@ def _process_single_reservation(
|
||||
HotelReservation = RetrieveHotelReservation
|
||||
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
|
||||
else:
|
||||
raise ValueError(f"Unsupported message type: {message_type}")
|
||||
raise ValueError("Unsupported message type: %s", message_type.value)
|
||||
|
||||
# UniqueID
|
||||
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_string)
|
||||
@@ -779,6 +778,7 @@ def _process_single_reservation(
|
||||
)
|
||||
|
||||
# shorten klick_id if longer than 64 characters
|
||||
# TODO MAGIC SHORTENING
|
||||
if klick_id is not None and len(klick_id) > 64:
|
||||
klick_id = klick_id[:64]
|
||||
|
||||
|
||||
@@ -9,13 +9,7 @@ from functools import partial
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
FastAPI,
|
||||
HTTPException,
|
||||
Request,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
@@ -28,22 +22,11 @@ from .alpinebits_server import (
|
||||
AlpineBitsServer,
|
||||
Version,
|
||||
)
|
||||
from .auth import (
|
||||
generate_api_key,
|
||||
generate_unique_id,
|
||||
validate_api_key,
|
||||
)
|
||||
from .auth import generate_api_key, generate_unique_id, validate_api_key
|
||||
from .config_loader import load_config
|
||||
from .db import (
|
||||
Base,
|
||||
get_database_url,
|
||||
)
|
||||
from .db import (
|
||||
Customer as DBCustomer,
|
||||
)
|
||||
from .db import (
|
||||
Reservation as DBReservation,
|
||||
)
|
||||
from .db import Base, get_database_url
|
||||
from .db import Customer as DBCustomer
|
||||
from .db import Reservation as DBReservation
|
||||
from .rate_limit import (
|
||||
BURST_RATE_LIMIT,
|
||||
DEFAULT_RATE_LIMIT,
|
||||
@@ -326,8 +309,7 @@ async def health_check(request: Request):
|
||||
|
||||
# Extracted business logic for handling Wix form submissions
|
||||
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
|
||||
"""Shared business logic for handling Wix form submissions (test and production).
|
||||
"""
|
||||
"""Shared business logic for handling Wix form submissions (test and production)."""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
_LOGGER.info(f"Received Wix form data at {timestamp}")
|
||||
@@ -423,6 +405,7 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
||||
|
||||
unique_id = data.get("submissionId", generate_unique_id())
|
||||
|
||||
# TODO MAGIC shortening
|
||||
if len(unique_id) > 32:
|
||||
# strip to first 35 chars
|
||||
unique_id = unique_id[:32]
|
||||
@@ -503,7 +486,7 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
||||
await dispatcher.dispatch_for_hotel(
|
||||
"form_processed", hotel_code, db_customer, db_reservation
|
||||
)
|
||||
_LOGGER.info(f"Dispatched form_processed event for hotel {hotel_code}")
|
||||
_LOGGER.info("Dispatched form_processed event for hotel %s", hotel_code)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"No hotel_code in reservation, skipping push notifications"
|
||||
@@ -556,6 +539,7 @@ async def handle_wix_form_test(
|
||||
raise HTTPException(status_code=500, detail="Error processing test data")
|
||||
|
||||
|
||||
# UNUSED
|
||||
@api_router.post("/admin/generate-api-key")
|
||||
@limiter.limit("5/hour") # Very restrictive for admin operations
|
||||
async def generate_new_api_key(
|
||||
@@ -583,6 +567,7 @@ 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.
|
||||
"""
|
||||
# Accept any username/password pair present in config['alpine_bits_auth']
|
||||
@@ -609,11 +594,13 @@ async def validate_basic_auth(
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
_LOGGER.info(
|
||||
f"AlpineBits authentication successful for user: {credentials.username} (from config)"
|
||||
"AlpineBits authentication successful for user: %s (from config)",
|
||||
credentials.username,
|
||||
)
|
||||
return credentials.username, credentials.password
|
||||
|
||||
|
||||
# TODO Bit sketchy. May need requests-toolkit in the future
|
||||
def parse_multipart_data(content_type: str, body: bytes) -> dict[str, Any]:
|
||||
"""Parse multipart/form-data from raw request body.
|
||||
This is a simplified parser for the AlpineBits use case.
|
||||
@@ -705,12 +692,12 @@ async def alpinebits_server_handshake(
|
||||
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(f"Client protocol version: {client_protocol_version}")
|
||||
_LOGGER.info("Client protocol version: %s", client_protocol_version)
|
||||
|
||||
# Optional client ID
|
||||
client_id = request.headers.get("X-AlpineBits-ClientID")
|
||||
if client_id:
|
||||
_LOGGER.info(f"Client ID: {client_id}")
|
||||
_LOGGER.info("Client ID: %s", client_id)
|
||||
|
||||
# Check content encoding
|
||||
content_encoding = request.headers.get("Content-Encoding")
|
||||
@@ -722,50 +709,14 @@ async def alpinebits_server_handshake(
|
||||
# Get content type before processing
|
||||
content_type = request.headers.get("Content-Type", "")
|
||||
|
||||
_LOGGER.info(f"Content-Type: {content_type}")
|
||||
_LOGGER.info(f"Content-Encoding: {content_encoding}")
|
||||
_LOGGER.info("Content-Type: %s", content_type)
|
||||
_LOGGER.info("Content-Encoding: %s", content_encoding)
|
||||
|
||||
# Get request body
|
||||
body = await request.body()
|
||||
|
||||
# Decompress if needed
|
||||
if is_compressed:
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Failed to decompress gzip content",
|
||||
)
|
||||
|
||||
# Check content type (after decompression)
|
||||
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",
|
||||
)
|
||||
|
||||
# Parse multipart data
|
||||
if "multipart/form-data" in content_type:
|
||||
try:
|
||||
form_data = parse_multipart_data(content_type, body)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Failed to parse multipart/form-data",
|
||||
)
|
||||
elif "application/x-www-form-urlencoded" in content_type:
|
||||
# Parse as urlencoded
|
||||
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||
)
|
||||
form_data = validate_alpinebits_body(is_compressed, content_type, body)
|
||||
|
||||
# Check for required action parameter
|
||||
action = form_data.get("action")
|
||||
@@ -807,6 +758,11 @@ async def alpinebits_server_handshake(
|
||||
"X-AlpineBits-Server-Version": "2024-10",
|
||||
}
|
||||
|
||||
if is_compressed:
|
||||
# Compress response if client sent compressed request
|
||||
response_xml = gzip.compress(response_xml.encode("utf-8"))
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
|
||||
return Response(
|
||||
content=response_xml, status_code=response.status_code, headers=headers
|
||||
)
|
||||
@@ -819,6 +775,49 @@ async def alpinebits_server_handshake(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
def validate_alpinebits_body(is_compressed, content_type, body):
|
||||
"""Check if the body conforms to AlpineBits expectations."""
|
||||
if is_compressed:
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Failed to decompress gzip content",
|
||||
)
|
||||
|
||||
# Check content type (after decompression)
|
||||
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",
|
||||
)
|
||||
|
||||
# Parse multipart data
|
||||
if "multipart/form-data" in content_type:
|
||||
try:
|
||||
form_data = parse_multipart_data(content_type, body)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Failed to parse multipart/form-data",
|
||||
)
|
||||
elif "application/x-www-form-urlencoded" in content_type:
|
||||
# Parse as urlencoded
|
||||
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
@api_router.get("/admin/stats")
|
||||
@limiter.limit("10/minute")
|
||||
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
|
||||
@@ -847,12 +846,9 @@ app.include_router(api_router)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def landing_page():
|
||||
"""Serve the under construction landing page at the root route
|
||||
"""
|
||||
"""Serve the under construction landing page at the root route."""
|
||||
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, encoding="utf-8") as f:
|
||||
|
||||
64
tests/test_output/actual_ping_response.xml
Normal file
64
tests/test_output/actual_ping_response.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_PingRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<Success/>
|
||||
<Warnings>
|
||||
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</Warning>
|
||||
</Warnings>
|
||||
<EchoData>{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</EchoData>
|
||||
</OTA_PingRS>
|
||||
81
tests/test_output/expected_ping_response.xml
Normal file
81
tests/test_output/expected_ping_response.xml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!--
|
||||
AlpineBits 2024-10
|
||||
https://www.alpinebits.org/
|
||||
|
||||
Sample message file for a Handshake response
|
||||
|
||||
Changelog:
|
||||
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
|
||||
v. 2024-10 1.1 Removed the OTA_Ping action
|
||||
v. 2024-10 1.0 added supported version 2024-10 in the example
|
||||
v. 2018-10 1.0 initial example
|
||||
-->
|
||||
|
||||
<OTA_PingRS xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRS.xsd"
|
||||
Version="8.000">
|
||||
<Success/>
|
||||
<Warnings>
|
||||
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</Warning>
|
||||
</Warnings>
|
||||
<EchoData>{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</EchoData>
|
||||
</OTA_PingRS>
|
||||
Reference in New Issue
Block a user