More cleanup.
This commit is contained in:
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 isinstance(data, HotelReservationIdData):
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
||||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
|
||||||
data
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(data, CommentsData):
|
if isinstance(data, CommentsData):
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
@@ -668,6 +666,7 @@ class AlpineBitsFactory:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_res_retrieve_response(list: list[tuple[Reservation, Customer]]):
|
def create_res_retrieve_response(list: list[tuple[Reservation, Customer]]):
|
||||||
@@ -726,7 +725,7 @@ def _process_single_reservation(
|
|||||||
HotelReservation = RetrieveHotelReservation
|
HotelReservation = RetrieveHotelReservation
|
||||||
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
|
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported message type: {message_type}")
|
raise ValueError("Unsupported message type: %s", message_type.value)
|
||||||
|
|
||||||
# UniqueID
|
# UniqueID
|
||||||
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_string)
|
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_string)
|
||||||
@@ -779,6 +778,7 @@ def _process_single_reservation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# shorten klick_id if longer than 64 characters
|
# shorten klick_id if longer than 64 characters
|
||||||
|
# TODO MAGIC SHORTENING
|
||||||
if klick_id is not None and len(klick_id) > 64:
|
if klick_id is not None and len(klick_id) > 64:
|
||||||
klick_id = klick_id[:64]
|
klick_id = klick_id[:64]
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ from .alpinebits_server import (
|
|||||||
)
|
)
|
||||||
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 .config_loader import load_config
|
||||||
from .db import Base
|
from .db import Base, get_database_url
|
||||||
from .db import Customer as DBCustomer
|
from .db import Customer as DBCustomer
|
||||||
from .db import Reservation as DBReservation
|
from .db import Reservation as DBReservation
|
||||||
from .db import get_database_url
|
|
||||||
from .rate_limit import (
|
from .rate_limit import (
|
||||||
BURST_RATE_LIMIT,
|
BURST_RATE_LIMIT,
|
||||||
DEFAULT_RATE_LIMIT,
|
DEFAULT_RATE_LIMIT,
|
||||||
@@ -406,6 +405,7 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
|
|
||||||
unique_id = data.get("submissionId", generate_unique_id())
|
unique_id = data.get("submissionId", generate_unique_id())
|
||||||
|
|
||||||
|
# TODO MAGIC shortening
|
||||||
if len(unique_id) > 32:
|
if len(unique_id) > 32:
|
||||||
# strip to first 35 chars
|
# strip to first 35 chars
|
||||||
unique_id = unique_id[:32]
|
unique_id = unique_id[:32]
|
||||||
@@ -486,7 +486,7 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
await dispatcher.dispatch_for_hotel(
|
await dispatcher.dispatch_for_hotel(
|
||||||
"form_processed", hotel_code, db_customer, db_reservation
|
"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:
|
else:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"No hotel_code in reservation, skipping push notifications"
|
"No hotel_code in reservation, skipping push notifications"
|
||||||
@@ -539,6 +539,7 @@ async def handle_wix_form_test(
|
|||||||
raise HTTPException(status_code=500, detail="Error processing test data")
|
raise HTTPException(status_code=500, detail="Error processing test data")
|
||||||
|
|
||||||
|
|
||||||
|
# UNUSED
|
||||||
@api_router.post("/admin/generate-api-key")
|
@api_router.post("/admin/generate-api-key")
|
||||||
@limiter.limit("5/hour") # Very restrictive for admin operations
|
@limiter.limit("5/hour") # Very restrictive for admin operations
|
||||||
async def generate_new_api_key(
|
async def generate_new_api_key(
|
||||||
@@ -566,6 +567,7 @@ async def validate_basic_auth(
|
|||||||
credentials: HTTPBasicCredentials = Depends(security_basic),
|
credentials: HTTPBasicCredentials = Depends(security_basic),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Validate basic authentication for AlpineBits protocol.
|
"""Validate basic authentication for AlpineBits protocol.
|
||||||
|
|
||||||
Returns username if valid, raises HTTPException if not.
|
Returns username if valid, raises HTTPException if not.
|
||||||
"""
|
"""
|
||||||
# Accept any username/password pair present in config['alpine_bits_auth']
|
# Accept any username/password pair present in config['alpine_bits_auth']
|
||||||
@@ -592,11 +594,13 @@ async def validate_basic_auth(
|
|||||||
headers={"WWW-Authenticate": "Basic"},
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
)
|
)
|
||||||
_LOGGER.info(
|
_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
|
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]:
|
def parse_multipart_data(content_type: str, body: bytes) -> dict[str, Any]:
|
||||||
"""Parse multipart/form-data from raw request body.
|
"""Parse multipart/form-data from raw request body.
|
||||||
This is a simplified parser for the AlpineBits use case.
|
This is a simplified parser for the AlpineBits use case.
|
||||||
@@ -688,12 +692,12 @@ async def alpinebits_server_handshake(
|
|||||||
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.info(f"Client protocol version: {client_protocol_version}")
|
_LOGGER.info("Client protocol version: %s", client_protocol_version)
|
||||||
|
|
||||||
# Optional client ID
|
# Optional client ID
|
||||||
client_id = request.headers.get("X-AlpineBits-ClientID")
|
client_id = request.headers.get("X-AlpineBits-ClientID")
|
||||||
if client_id:
|
if client_id:
|
||||||
_LOGGER.info(f"Client ID: {client_id}")
|
_LOGGER.info("Client ID: %s", client_id)
|
||||||
|
|
||||||
# Check content encoding
|
# Check content encoding
|
||||||
content_encoding = request.headers.get("Content-Encoding")
|
content_encoding = request.headers.get("Content-Encoding")
|
||||||
@@ -705,50 +709,14 @@ async def alpinebits_server_handshake(
|
|||||||
# Get content type before processing
|
# Get content type before processing
|
||||||
content_type = request.headers.get("Content-Type", "")
|
content_type = request.headers.get("Content-Type", "")
|
||||||
|
|
||||||
_LOGGER.info(f"Content-Type: {content_type}")
|
_LOGGER.info("Content-Type: %s", content_type)
|
||||||
_LOGGER.info(f"Content-Encoding: {content_encoding}")
|
_LOGGER.info("Content-Encoding: %s", content_encoding)
|
||||||
|
|
||||||
# Get request body
|
# Get request body
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
# Decompress if needed
|
# Decompress if needed
|
||||||
if is_compressed:
|
form_data = validate_alpinebits_body(is_compressed, content_type, body)
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for required action parameter
|
# Check for required action parameter
|
||||||
action = form_data.get("action")
|
action = form_data.get("action")
|
||||||
@@ -807,6 +775,49 @@ async def alpinebits_server_handshake(
|
|||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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")
|
@api_router.get("/admin/stats")
|
||||||
@limiter.limit("10/minute")
|
@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)):
|
||||||
|
|||||||
Reference in New Issue
Block a user