More cleanup.

This commit is contained in:
Jonas Linter
2025-10-07 15:12:46 +02:00
parent 122c7c8be4
commit 5ec47b8332
4 changed files with 135 additions and 49 deletions

24
99Tales_Testexport.xml Normal file
View 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>

View File

@@ -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>

View File

@@ -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]

View File

@@ -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)):