12 Commits

Author SHA1 Message Date
Jonas Linter
f05cc9215e Updated config 2025-10-09 14:16:11 +02:00
Jonas Linter
162ef39013 added logging config. Not active yet 2025-10-09 11:06:22 +02:00
Jonas Linter
ac57999a85 Changed some logging statements 2025-10-09 10:59:24 +02:00
Jonas Linter
7d3d63db56 Fixing some Linter mistakes 2025-10-09 10:54:33 +02:00
Jonas Linter
b9adb8c7d9 submission as creation time for reservations 2025-10-09 10:04:34 +02:00
Jonas Linter
95b17b8776 I think acknowledgments work just fine now 2025-10-09 09:38:54 +02:00
Jonas Linter
1b3ebb3cad Mucking around with the tests 2025-10-09 09:29:01 +02:00
Jonas Linter
18d30a140f Fixed SelectionCriteria Filtering. Date wasn't added to pydantic model 2025-10-09 09:22:52 +02:00
Jonas Linter
69fb1374b2 Updated sizes of certain string fields 2025-10-09 08:45:06 +02:00
Jonas Linter
bbac8060b9 Created new tests for acknowlegments. One fails atm 2025-10-08 16:48:38 +02:00
Jonas Linter
dba07fc5ff Python env now autoopens 2025-10-08 16:18:20 +02:00
Jonas Linter
44abe3ed35 VScode can now test hurray 2025-10-08 16:14:00 +02:00
13 changed files with 734 additions and 389 deletions

6
.env Normal file
View File

@@ -0,0 +1,6 @@
# Environment variables for development
# You can add project-specific environment variables here
# Example:
# ALPINEBITS_CONFIG_DIR=./config
# PYTHONPATH=./src

2
.gitignore vendored
View File

@@ -19,6 +19,8 @@ test_data/*
test/test_output/*
logs/*
# ignore secrets
secrets.yaml

38
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"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"
}
},
{
"name": "Python: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true
},
{
"name": "Python: API Server",
"type": "debugpy",
"request": "launch",
"module": "alpine_bits_python.run_api",
"console": "integratedTerminal",
"justMyCode": true,
"env": {
"ALPINEBITS_CONFIG_DIR": "${workspaceFolder}/config"
}
}
]
}

47
.vscode/settings.json vendored
View File

@@ -18,7 +18,31 @@
"notebook.output.wordWrap": true,
"notebook.output.textLineLimit": 200,
"jupyter.debugJustMyCode": false,
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.envFile": "${workspaceFolder}/.env",
"terminal.integrated.env.linux": {
"VIRTUAL_ENV": "${workspaceFolder}/.venv",
"PATH": "${workspaceFolder}/.venv/bin:${env:PATH}"
},
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"args": ["-c", "source ${workspaceFolder}/.venv/bin/activate && exec bash"]
}
},
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"tests",
"-v",
"--tb=short"
],
"python.testing.pytestPath": "./.venv/bin/pytest",
"python.testing.unittestEnabled": false,
"python.testing.autoTestDiscoverOnSaveEnabled": true,
"python.testing.cwd": "${workspaceFolder}",
"files.exclude": {
"**/*.egg-info": true,
"**/htmlcov": true,
@@ -27,27 +51,6 @@
"**/.venv": true,
"**/__pycache__": true,
"**/.mypy_cache": true,
"**/.pytest_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"
}
}
]
}

View File

@@ -8,6 +8,18 @@ database:
# AlpineBits Python config
# Use annotatedyaml for secrets and environment-specific overrides
server:
codecontext: "ADVERTISING"
code: 70597314
companyname: "99tales Gmbh"
res_id_source_context: "99tales"
logger:
level: "INFO" # Set to DEBUG for more verbose output
file: "alpinebits.log" # Log file path, or null for console only
alpine_bits_auth:
- hotel_id: "39054_001"
hotel_name: "Bemelmans Post"

13
conftest.py Normal file
View File

@@ -0,0 +1,13 @@
"""Pytest configuration and path setup for VS Code.
This configuration file ensures that VS Code can properly discover and run tests
by setting up the Python path to include the src directory.
"""
import sys
from pathlib import Path
# Add the src directory to Python path for VS Code test discovery
src_path = Path(__file__).parent / "src"
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))

View File

@@ -40,111 +40,110 @@ testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
src = ["src", "test"]
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"A001", # Variable {name} is shadowing a Python builtin
"A001", # Variable {name} is shadowing a Python builtin
"ASYNC210", # Async functions should not call blocking HTTP methods
"ASYNC220", # Async functions should not create subprocesses with blocking methods
"ASYNC221", # Async functions should not run processes with blocking methods
"ASYNC222", # Async functions should not wait on processes with blocking methods
"ASYNC230", # Async functions should not open files with blocking methods like open
"ASYNC251", # Async functions should not call time.sleep
"B002", # Python does not support the unary prefix increment
"B005", # Using .strip() with multi-character strings is misleading
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
"B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name}
"B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
"B035", # Dictionary comprehension uses static key
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"B002", # Python does not support the unary prefix increment
"B005", # Using .strip() with multi-character strings is misleading
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
"B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name}
"B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
"B035", # Dictionary comprehension uses static key
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"BLE",
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"F541", # f-string without any placeholders
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"ICN001", # import concentions; {name} should be imported as {asname}
"LOG", # flake8-logging
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PERF", # Perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PL", # pylint
"PT", # flake8-pytest-style
"PTH", # flake8-pathlib
"PYI", # flake8-pyi
"RET", # flake8-return
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
"RUF008", # Do not use mutable default values for dataclass attributes
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
"RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
"RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access
"RUF020", # {never_like} | T is equivalent to T
"RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
"RUF022", # Sort __all__
"RUF023", # Sort __slots__
"RUF024", # Do not pass mutable objects as values to dict.fromkeys
"RUF026", # default_factory is a positional-only argument to defaultdict
"RUF030", # print() call in assert statement is likely unintentional
"RUF032", # Decimal() called with float literal argument
"RUF033", # __post_init__ method with argument defaults
"RUF034", # Useless if-else condition
"RUF100", # Unused `noqa` directive
"RUF101", # noqa directives that use redirected rule codes
"RUF200", # Failed to parse pyproject.toml: {message}
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM", # flake8-simplify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
"TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call
"W", # pycodestyle
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"F541", # f-string without any placeholders
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"ICN001", # import concentions; {name} should be imported as {asname}
"LOG", # flake8-logging
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PERF", # Perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PL", # pylint
"PT", # flake8-pytest-style
"PTH", # flake8-pathlib
"PYI", # flake8-pyi
"RET", # flake8-return
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
"RUF008", # Do not use mutable default values for dataclass attributes
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
"RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
"RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access
"RUF020", # {never_like} | T is equivalent to T
"RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
"RUF022", # Sort __all__
"RUF023", # Sort __slots__
"RUF024", # Do not pass mutable objects as values to dict.fromkeys
"RUF026", # default_factory is a positional-only argument to defaultdict
"RUF030", # print() call in assert statement is likely unintentional
"RUF032", # Decimal() called with float literal argument
"RUF033", # __post_init__ method with argument defaults
"RUF034", # Useless if-else condition
"RUF100", # Unused `noqa` directive
"RUF101", # noqa directives that use redirected rule codes
"RUF200", # Failed to parse pyproject.toml: {message}
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM", # flake8-simplify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
"TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call
"W", # pycodestyle
]

View File

@@ -1,7 +1,7 @@
import logging
import traceback
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC
from enum import Enum
from typing import Any
@@ -81,6 +81,11 @@ class OtaMessageType(Enum):
RETRIEVE = "retrieve" # For OtaResRetrieveRs
RESERVATION_ID_TYPE: str = (
"13" # Default reservation ID type for Reservation. 14 would be cancellation
)
@dataclass
class KidsAgeData:
"""Data class to hold information about children's ages."""
@@ -389,8 +394,11 @@ class CommentFactory:
# Create list items
list_items = []
for item_data in comment_data.list_items:
_LOGGER.info(
f"Creating list item: value={item_data.value}, list_item={item_data.list_item}, language={item_data.language}"
_LOGGER.debug(
"Creating list item: value=%s, list_item=%s, language=%s",
item_data.value,
item_data.list_item,
item_data.language,
)
list_item = comment_class.ListItem(
@@ -601,19 +609,24 @@ class AlpineBitsFactory:
def create_res_retrieve_response(
list: list[tuple[Reservation, Customer]],
list: list[tuple[Reservation, Customer]], config: dict[str, Any]
) -> OtaResRetrieveRs:
"""Create RetrievedReservation XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
return _create_xml_from_db(list, OtaMessageType.RETRIEVE, config)
def create_res_notif_push_message(list: tuple[Reservation, Customer]):
def create_res_notif_push_message(
list: tuple[Reservation, Customer], config: dict[str, Any]
):
"""Create Reservation Notification XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.NOTIF)
return _create_xml_from_db(list, OtaMessageType.NOTIF, config)
def _process_single_reservation(
reservation: Reservation, customer: Customer, message_type: OtaMessageType
reservation: Reservation,
customer: Customer,
message_type: OtaMessageType,
config: dict[str, Any],
):
phone_numbers = (
[(customer.phone, PhoneTechType.MOBILE)] if customer.phone is not None else []
@@ -695,11 +708,14 @@ def _process_single_reservation(
# - Trim whitespace
# - Truncate to 64 characters if needed
# - Convert empty strings to None
res_id_source_context = config["server"]["res_id_source_context"]
hotel_res_id_data = HotelReservationIdData(
res_id_type="13",
res_id_type=RESERVATION_ID_TYPE,
res_id_value=klick_id,
res_id_source=res_id_source,
res_id_source_context="99tales",
res_id_source_context=res_id_source_context,
)
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
@@ -755,15 +771,22 @@ def _process_single_reservation(
comments_xml = None
if comments:
for c in comments:
_LOGGER.info(
f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}"
_LOGGER.debug(
"Creating comment: name=%s, text=%s, list_items=%s",
c.name,
c.text,
len(c.list_items),
)
comments_data = CommentsData(comments=comments)
comments_xml = alpine_bits_factory.create(comments_data, message_type)
company_name_value = config["server"]["companyname"]
company_code = config["server"]["code"]
codecontext = config["server"]["codecontext"]
company_name = Profile.CompanyInfo.CompanyName(
value="99tales GmbH", code="who knows?", code_context="who knows?"
value=company_name_value, code=company_code, code_context=codecontext
)
company_info = Profile.CompanyInfo(company_name=company_name)
@@ -774,7 +797,7 @@ def _process_single_reservation(
profile_info = HotelReservation.ResGlobalInfo.Profiles.ProfileInfo(profile=profile)
_LOGGER.info(f"Type of profile_info: {type(profile_info)}")
_LOGGER.info("Type of profile_info: %s", type(profile_info))
profiles = HotelReservation.ResGlobalInfo.Profiles(profile_info=profile_info)
@@ -785,8 +808,8 @@ def _process_single_reservation(
profiles=profiles,
)
hotel_reservation = HotelReservation(
create_date_time=datetime.now(UTC).isoformat(),
return HotelReservation(
create_date_time=reservation.created_at.replace(tzinfo=UTC).isoformat(),
res_status=HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
@@ -795,12 +818,11 @@ def _process_single_reservation(
res_global_info=res_global_info,
)
return hotel_reservation
def _create_xml_from_db(
entries: list[tuple[Reservation, Customer]] | tuple[Reservation, Customer],
type: OtaMessageType,
config: dict[str, Any],
):
"""Create RetrievedReservation XML from database entries.
@@ -815,17 +837,23 @@ def _create_xml_from_db(
for reservation, customer in entries:
_LOGGER.info(
f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}"
"Creating XML for reservation %s and customer %s",
reservation.id,
customer.id,
)
try:
hotel_reservation = _process_single_reservation(reservation, customer, type)
hotel_reservation = _process_single_reservation(
reservation, customer, type, config
)
reservations_list.append(hotel_reservation)
except Exception as e:
_LOGGER.error(
f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}"
except Exception:
_LOGGER.exception(
"Error creating XML for reservation %s and customer %s",
reservation.unique_id,
customer.given_name,
)
_LOGGER.debug(traceback.format_exc())
@@ -840,8 +868,8 @@ def _create_xml_from_db(
try:
ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
except Exception as e:
_LOGGER.error(f"Validation error: {e}")
except Exception:
_LOGGER.exception("Validation error: ")
raise
return ota_hotel_res_notif_rq
@@ -863,189 +891,3 @@ def _create_xml_from_db(
return ota_res_retrieve_rs
raise ValueError(f"Unsupported message type: {type}")
# Usage examples
if __name__ == "__main__":
# Create customer data using simple data class
customer_data = CustomerData(
given_name="John",
surname="Doe",
name_prefix="Mr.",
phone_numbers=[
("+1234567890", PhoneTechType.MOBILE), # Phone number with type
("+0987654321", None), # Phone number without type
],
email_address="john.doe@example.com",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
address_catalog=False,
gender="Male",
birth_date="1980-01-01",
language="en",
)
# Create customer for OtaHotelResNotifRq
notif_customer = CustomerFactory.create_notif_customer(customer_data)
print(
"Created NotifCustomer:",
notif_customer.person_name.given_name,
notif_customer.person_name.surname,
)
# Create customer for OtaResRetrieveRs
retrieve_customer = CustomerFactory.create_retrieve_customer(customer_data)
print(
"Created RetrieveCustomer:",
retrieve_customer.person_name.given_name,
retrieve_customer.person_name.surname,
)
# Convert back to data class
converted_data = CustomerFactory.from_notif_customer(notif_customer)
print("Converted back to data:", converted_data.given_name, converted_data.surname)
# Verify they contain the same information
print("Original and converted data match:", customer_data == converted_data)
print("\n--- HotelReservationIdFactory Examples ---")
# Create hotel reservation ID data
reservation_id_data = HotelReservationIdData(
res_id_type="123",
res_id_value="RESERVATION-456",
res_id_source="HOTEL_SYSTEM",
res_id_source_context="BOOKING_ENGINE",
)
# Create HotelReservationId for both types
notif_res_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
reservation_id_data
)
retrieve_res_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
reservation_id_data
)
print(
"Created NotifHotelReservationId:",
notif_res_id.res_id_type,
notif_res_id.res_id_value,
)
print(
"Created RetrieveHotelReservationId:",
retrieve_res_id.res_id_type,
retrieve_res_id.res_id_value,
)
# Convert back to data class
converted_res_id_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(
notif_res_id
)
print(
"Converted back to reservation ID data:",
converted_res_id_data.res_id_type,
converted_res_id_data.res_id_value,
)
# Verify they contain the same information
print(
"Original and converted reservation ID data match:",
reservation_id_data == converted_res_id_data,
)
print("\n--- ResGuestFactory Examples ---")
# Create complete ResGuests structure for OtaHotelResNotifRq - much simpler!
notif_res_guests = ResGuestFactory.create_notif_res_guests(customer_data)
print(
"Created NotifResGuests with customer:",
notif_res_guests.res_guest.profiles.profile_info.profile.customer.person_name.given_name,
)
# Create complete ResGuests structure for OtaResRetrieveRs - much simpler!
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(customer_data)
print(
"Created RetrieveResGuests with customer:",
retrieve_res_guests.res_guest.profiles.profile_info.profile.customer.person_name.given_name,
)
# Extract primary customer data back from ResGuests structure
extracted_data = ResGuestFactory.extract_primary_customer(retrieve_res_guests)
print("Extracted customer data:", extracted_data.given_name, extracted_data.surname)
# Verify roundtrip conversion
print("Roundtrip conversion successful:", customer_data == extracted_data)
print("\n--- Unified AlpineBitsFactory Examples ---")
# Much simpler approach - single factory with enum parameter!
print("=== Customer Creation ===")
notif_customer = AlpineBitsFactory.create(customer_data, OtaMessageType.NOTIF)
retrieve_customer = AlpineBitsFactory.create(customer_data, OtaMessageType.RETRIEVE)
print("Created customers using unified factory")
print("=== HotelReservationId Creation ===")
reservation_id_data = HotelReservationIdData(
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
)
print("Created reservation IDs using unified factory")
print("=== Comments Creation ===")
comments_data = CommentsData(
comments=[
CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text="This is a customer comment about the reservation",
list_items=[
CommentListItemData(
value="Special dietary requirements: vegetarian",
list_item="1",
language="en",
),
CommentListItemData(
value="Late arrival expected", list_item="2", language="en"
),
],
),
CommentData(
name=CommentName2.ADDITIONAL_INFO,
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
)
print("Created ResGuests using unified factory")
print("=== Data Extraction ===")
# Extract data back using unified interface
extracted_customer_data = AlpineBitsFactory.extract_data(notif_customer)
extracted_res_id_data = AlpineBitsFactory.extract_data(notif_res_id)
extracted_comments_data = AlpineBitsFactory.extract_data(retrieve_comments)
extracted_from_res_guests = AlpineBitsFactory.extract_data(retrieve_res_guests)
print("Data extraction successful:")
print("- Customer roundtrip:", customer_data == extracted_customer_data)
print("- ReservationId roundtrip:", reservation_id_data == extracted_res_id_data)
print("- Comments roundtrip:", comments_data == extracted_comments_data)
print("- ResGuests roundtrip:", customer_data == extracted_from_res_guests)
print("\n--- Comparison with old approach ---")
print("Old way required multiple imports and knowing specific factory methods")
print("New way: single import, single factory, enum parameter to specify type!")

View File

@@ -344,7 +344,7 @@ class PingAction(AlpineBitsAction):
# compare echo data with capabilities, create a dictionary containing the matching capabilities
capabilities_dict = server_capabilities.get_capabilities_dict()
_LOGGER.info(f"Capabilities Dict: {capabilities_dict}")
_LOGGER.debug("Capabilities of Server: %s", capabilities_dict)
matching_capabilities = {"versions": []}
# Iterate through client's requested versions
@@ -505,6 +505,9 @@ class ReadAction(AlpineBitsAction):
start_date = None
"""When given, the server will send only inquiries generated after the Start timestamp, regardless
whether the client has retrieved them before or not."""
if hotel_read_request.selection_criteria is not None:
start_date = datetime.fromisoformat(
hotel_read_request.selection_criteria.start
@@ -518,7 +521,8 @@ class ReadAction(AlpineBitsAction):
.filter(Reservation.hotel_code == hotelid)
)
if start_date:
stmt = stmt.filter(Reservation.start_date >= start_date)
_LOGGER.info("Filtering reservations from start date %s", start_date)
stmt = stmt.filter(Reservation.created_at >= start_date)
# remove reservations that have been acknowledged via client_id
elif client_info.client_id:
subquery = (
@@ -537,14 +541,20 @@ class ReadAction(AlpineBitsAction):
) # List of (Reservation, Customer) tuples
_LOGGER.info(
f"Querying reservations and customers for hotel {hotelid} from database"
"Querying reservations and customers for hotel %s from database",
hotelid,
)
for reservation, customer in reservation_customer_pairs:
_LOGGER.info(
f"Reservation: {reservation.id}, Customer: {customer.given_name}"
"Retrieving reservation %s for customer %s %s",
reservation.id,
customer.given_name,
customer.surname,
)
res_retrive_rs = create_res_retrieve_response(reservation_customer_pairs)
res_retrive_rs = create_res_retrieve_response(
reservation_customer_pairs, config=self.config
)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
@@ -558,7 +568,7 @@ class ReadAction(AlpineBitsAction):
class NotifReportReadAction(AlpineBitsAction):
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
"""Necessary for read action to follow specification. Clients need to report acknowledgements."""
def __init__(self, config: dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
@@ -644,7 +654,9 @@ class PushAction(AlpineBitsAction):
server_capabilities=None,
) -> AlpineBitsResponse:
"""Create push request XML."""
xml_push_request = create_res_notif_push_message(request_xml)
xml_push_request = create_res_notif_push_message(
request_xml, config=self.config
)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"

View File

@@ -5,7 +5,7 @@ import logging
import os
import urllib.parse
from collections import defaultdict
from datetime import UTC, date, datetime
from datetime import date, datetime
from functools import partial
from pathlib import Path
from typing import Any
@@ -164,12 +164,12 @@ async def lifespan(app: FastAPI):
try:
config = load_config()
except Exception as e:
_LOGGER.error(f"Failed to load config: {e!s}")
except Exception:
_LOGGER.exception("Failed to load config: ")
config = {}
DATABASE_URL = get_database_url(config)
engine = create_async_engine(DATABASE_URL, echo=True)
engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app.state.engine = engine
@@ -189,12 +189,14 @@ async def lifespan(app: FastAPI):
"form_processed", hotel_id, partial(push_listener, hotel=hotel)
)
_LOGGER.info(
f"Registered push listener for hotel {hotel_id} with endpoint {push_endpoint.get('url')}"
"Registered push listener for hotel %s with endpoint %s",
hotel_id,
push_endpoint.get("url"),
)
elif push_endpoint and not hotel_id:
_LOGGER.warning(f"Hotel has push_endpoint but no hotel_id: {hotel}")
_LOGGER.warning("Hotel has push_endpoint but no hotel_id: %s", hotel)
elif hotel_id and not push_endpoint:
_LOGGER.info(f"Hotel {hotel_id} has no push_endpoint configured")
_LOGGER.info("Hotel %s has no push_endpoint configured", hotel_id)
# Create tables
async with engine.begin() as conn:
@@ -419,6 +421,16 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
or "Frangart Inn" # fallback
)
submissionTime = data.get("submissionTime") # 2025-10-07T05:48:41.855Z
try:
if submissionTime:
submissionTime = datetime.fromisoformat(
submissionTime[:-1]
) # Remove Z and convert
except Exception as e:
_LOGGER.exception("Error parsing submissionTime: %s", e)
submissionTime = None
reservation = ReservationData(
unique_id=unique_id,
start_date=date.fromisoformat(start_date),
@@ -429,7 +441,7 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
hotel_code=hotel_code,
hotel_name=hotel_name,
offer=offer,
created_at=datetime.now(UTC),
created_at=submissionTime,
utm_source=data.get("field:utm_source"),
utm_medium=data.get("field:utm_medium"),
utm_campaign=data.get("field:utm_campaign"),
@@ -519,17 +531,18 @@ 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.
"""
try:
return await process_wix_form_submission(request, data, db_session)
except Exception as e:
_LOGGER.error(f"Error in handle_wix_form: {e!s}")
_LOGGER.error("Error in handle_wix_form: %s", e)
# log stacktrace
import traceback
traceback_str = traceback.format_exc()
_LOGGER.error(f"Stack trace for handle_wix_form: {traceback_str}")
_LOGGER.exception("Stack trace for handle_wix_form: %s", traceback_str)
raise HTTPException(status_code=500, detail="Error processing Wix form data")

View File

@@ -1,12 +1,8 @@
import os
from pathlib import Path
from annotatedyaml.loader import (
Secrets,
)
from annotatedyaml.loader import (
load_yaml as load_annotated_yaml,
)
from annotatedyaml.loader import Secrets
from annotatedyaml.loader import load_yaml as load_annotated_yaml
from voluptuous import (
PREVENT_EXTRA,
All,
@@ -21,9 +17,35 @@ from voluptuous import (
database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA)
logger_schema = Schema(
{
Required("level"): str,
Optional("file"): str, # If not provided, log to console
},
extra=PREVENT_EXTRA,
)
def ensure_string(value):
"""Ensure the value is a string."""
if isinstance(value, str):
return value
return str(value)
server_info = Schema(
{
Required("codecontext", default="ADVERTISING"): ensure_string,
Required("code", default="70597314"): ensure_string,
Required("companyname", default="99tales Gmbh"): ensure_string,
Required("res_id_source_context", default="99tales"): ensure_string,
}
)
hotel_auth_schema = Schema(
{
Required("hotel_id"): str,
Required("hotel_id"): ensure_string,
Required("hotel_name"): str,
Required("username"): str,
Required("password"): str,
@@ -42,6 +64,8 @@ config_schema = Schema(
{
Required("database"): database_schema,
Required("alpine_bits_auth"): basic_auth_schema,
Required("server"): server_info,
Optional("logger", default={"level": "INFO", "file": None}): logger_schema,
},
extra=PREVENT_EXTRA,
)
@@ -52,7 +76,7 @@ DEFAULT_CONFIG_FILE = "config.yaml"
class Config:
def __init__(
self,
config_folder: str | Path = None,
config_folder: str | Path | None = None,
config_name: str = DEFAULT_CONFIG_FILE,
testing_mode: bool = False,
):

View File

@@ -10,7 +10,7 @@ from XML generation (xsdata) follows clean architecture principles.
"""
import hashlib
from datetime import date
from datetime import date, datetime
from enum import Enum
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
@@ -43,6 +43,7 @@ class ReservationData(BaseModel):
md5_unique_id: str | None = Field(None, min_length=1, max_length=32)
start_date: date
end_date: date
created_at: datetime = Field(default_factory=datetime.now)
num_adults: int = Field(..., ge=1)
num_children: int = Field(0, ge=0, le=10)
children_ages: list[int] = Field(default_factory=list)
@@ -50,13 +51,13 @@ class ReservationData(BaseModel):
hotel_name: str | None = Field(None, max_length=200)
offer: str | None = Field(None, max_length=500)
user_comment: str | None = Field(None, max_length=2000)
fbclid: str | None = Field(None, max_length=100)
gclid: str | None = Field(None, max_length=100)
utm_source: str | None = Field(None, max_length=100)
utm_medium: str | None = Field(None, max_length=100)
utm_campaign: str | None = Field(None, max_length=100)
utm_term: str | None = Field(None, max_length=100)
utm_content: str | None = Field(None, max_length=100)
fbclid: str | None = Field(None, max_length=300)
gclid: str | None = Field(None, max_length=300)
utm_source: str | None = Field(None, max_length=150)
utm_medium: str | None = Field(None, max_length=150)
utm_campaign: str | None = Field(None, max_length=150)
utm_term: str | None = Field(None, max_length=150)
utm_content: str | None = Field(None, max_length=150)
@model_validator(mode="after")
def ensure_md5(self) -> "ReservationData":

View File

@@ -4,22 +4,28 @@ This module tests the ReadAction handler which retrieves reservations
from the database and returns them as OTA_ResRetrieveRS XML.
"""
import hashlib
from datetime import UTC, date, datetime
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
from alpine_bits_python.alpine_bits_helpers import create_res_retrieve_response
from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo
from alpine_bits_python.db import Base, Customer, Reservation
from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer
from alpine_bits_python.db import AckedRequest, Base, Customer, Reservation
from alpine_bits_python.generated import OtaReadRq
from alpine_bits_python.generated.alpinebits import OtaResRetrieveRs
from alpine_bits_python.schemas import ReservationData
# HTTP status code constants
HTTP_OK = 200
@pytest.fixture
@pytest_asyncio.fixture
async def test_db_engine():
"""Create an in-memory SQLite database for testing."""
engine = create_async_engine(
@@ -37,7 +43,7 @@ async def test_db_engine():
await engine.dispose()
@pytest.fixture
@pytest_asyncio.fixture
async def test_db_session(test_db_engine):
"""Create a test database session."""
async_session = async_sessionmaker(
@@ -85,15 +91,15 @@ def sample_reservation(sample_customer):
num_children=1,
children_ages=[8],
offer="Christmas Special",
created_at=datetime.now(UTC),
created_at=datetime(2024, 11, 1, 12, 0, 0, tzinfo=UTC),
utm_source="google",
utm_medium="cpc",
utm_campaign="winter2024",
utm_term="ski resort",
utm_content="ad1",
user_comment="Late check-in requested",
fbclid="",
gclid="abc123xyz",
fbclid="PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA",
gclid="",
hotel_code="HOTEL123",
hotel_name="Alpine Paradise Resort",
)
@@ -103,8 +109,6 @@ def sample_reservation(sample_customer):
children_csv = ",".join(str(int(a)) for a in children_list) if children_list else ""
data["children_ages"] = children_csv
print(data)
return Reservation(
id=1,
customer_id=1,
@@ -135,7 +139,7 @@ def minimal_reservation(minimal_customer):
num_children=0,
children_ages=[],
hotel_code="HOTEL123",
created_at=datetime.now(UTC),
created_at=datetime(2024, 12, 2, 12, 0, 0, tzinfo=UTC),
hotel_name="Alpine Paradise Resort",
)
@@ -163,7 +167,7 @@ def read_request_xml():
Version="8.000">
<ReadRequests>
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort">
<SelectionCriteria Start="2024-12-01" End="2025-01-31"/>
<SelectionCriteria Start="2024-10-01" End="2025-01-31"/>
</HotelReadRequest>
</ReadRequests>
</OTA_ReadRQ>"""
@@ -187,13 +191,20 @@ def read_request_xml_no_date_filter():
def test_config():
"""Test configuration with hotel credentials."""
return {
"hotels": [
"server": {
"codecontext": "ADVERTISING",
"code": "70597314",
"companyname": "99tales Gmbh",
"res_id_source_context": "99tales",
},
"alpine_bits_auth": [
{
"hotel_id": "HOTEL123",
"hotel_name": "Alpine Paradise Resort",
"username": "testuser",
"password": "testpass",
}
]
],
}
@@ -210,9 +221,9 @@ def client_info():
class TestCreateResRetrieveResponse:
"""Test the create_res_retrieve_response function."""
def test_empty_list(self):
def test_empty_list(self, test_config):
"""Test creating response with empty reservation list."""
response = create_res_retrieve_response([])
response = create_res_retrieve_response([], config=test_config)
assert response is not None, "Response should not be None"
@@ -227,10 +238,10 @@ class TestCreateResRetrieveResponse:
"Response should have reservations_list attribute"
)
def test_single_reservation(self, sample_reservation, sample_customer):
def test_single_reservation(self, sample_reservation, sample_customer, test_config):
"""Test creating response with single reservation."""
reservation_pairs = [(sample_reservation, sample_customer)]
response = create_res_retrieve_response(reservation_pairs)
response = create_res_retrieve_response(reservation_pairs, config=test_config)
assert response is not None
assert hasattr(response, "reservations_list"), (
@@ -268,13 +279,14 @@ class TestCreateResRetrieveResponse:
sample_customer,
minimal_reservation,
minimal_customer,
test_config,
):
"""Test creating response with multiple reservations."""
reservation_pairs = [
(sample_reservation, sample_customer),
(minimal_reservation, minimal_customer),
]
response = create_res_retrieve_response(reservation_pairs)
response = create_res_retrieve_response(reservation_pairs, config=test_config)
assert response is not None
@@ -292,13 +304,15 @@ class TestCreateResRetrieveResponse:
assert "John" in xml_output
assert "Jane" in xml_output
def test_reservation_with_children(self, sample_reservation, sample_customer):
def test_reservation_with_children(
self, sample_reservation, sample_customer, test_config
):
"""Test reservation with children ages."""
sample_reservation.num_children = 2
sample_reservation.children_ages = "8,5"
reservation_pairs = [(sample_reservation, sample_customer)]
response = create_res_retrieve_response(reservation_pairs)
response = create_res_retrieve_response(reservation_pairs, config=test_config)
config = SerializerConfig(pretty_print=True)
serializer = XmlSerializer(config=config)
@@ -327,7 +341,7 @@ class TestXMLParsing:
assert hotel_req.hotel_code == "HOTEL123"
assert hotel_req.hotel_name == "Alpine Paradise Resort"
assert hotel_req.selection_criteria is not None
assert hotel_req.selection_criteria.start == "2024-12-01"
assert hotel_req.selection_criteria.start == "2024-10-01"
def test_parse_read_request_no_date(self, read_request_xml_no_date_filter):
"""Test parsing of OTA_ReadRQ without date filter."""
@@ -343,10 +357,11 @@ class TestXMLParsing:
self,
sample_reservation,
sample_customer,
test_config,
):
"""Test serialization of retrieve response to XML."""
reservation_pairs = [(sample_reservation, sample_customer)]
response = create_res_retrieve_response(reservation_pairs)
response = create_res_retrieve_response(reservation_pairs, config=test_config)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
@@ -373,7 +388,7 @@ class TestXMLParsing:
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_customer_with_special_characters(self):
def test_customer_with_special_characters(self, test_config):
"""Test customer with special characters in name."""
customer = Customer(
id=99,
@@ -395,7 +410,7 @@ class TestEdgeCases:
)
reservation_pairs = [(reservation, customer)]
response = create_res_retrieve_response(reservation_pairs)
response = create_res_retrieve_response(reservation_pairs, config=test_config)
config = SerializerConfig(pretty_print=True, encoding="UTF-8")
serializer = XmlSerializer(config=config)
@@ -406,7 +421,7 @@ class TestEdgeCases:
assert response is not None
assert xml_output is not None
def test_reservation_with_all_utm_parameters(self):
def test_reservation_with_all_utm_parameters(self, test_config):
"""Test reservation with all UTM tracking parameters."""
customer = Customer(
id=97,
@@ -439,7 +454,7 @@ class TestEdgeCases:
)
reservation_pairs = [(reservation_db, customer)]
response = create_res_retrieve_response(reservation_pairs)
response = create_res_retrieve_response(reservation_pairs, config=test_config)
config = SerializerConfig(pretty_print=True)
serializer = XmlSerializer(config=config)
@@ -451,5 +466,370 @@ class TestEdgeCases:
# UTM parameters should be in comments or other fields
class TestAcknowledgments:
"""Test acknowledgments.
1. Setup AlpineBitsServer so that it can respond to sample read requests.
2. Send acknowledgment requests and verify responses.
3. Verify that acknowledgments are recorded in the database.
4. Verify that Read Requests no longer return already acknowledged reservations.
5. Verify that that still happens when SelectionCriteria date filters are applied.
"""
@pytest_asyncio.fixture
async def populated_db_session(
self,
test_db_session,
sample_reservation,
sample_customer,
minimal_reservation,
minimal_customer,
):
"""Create a database session with sample data."""
# Add customers
test_db_session.add(sample_customer)
test_db_session.add(minimal_customer)
await test_db_session.commit()
# Add reservations
test_db_session.add(sample_reservation)
test_db_session.add(minimal_reservation)
await test_db_session.commit()
return test_db_session
@pytest.fixture
def alpinebits_server(self, test_config):
"""Create AlpineBitsServer instance for testing."""
return AlpineBitsServer(config=test_config)
@pytest.fixture
def notif_report_xml_template(self):
"""Template for OTA_NotifReportRQ XML request."""
return """<?xml version="1.0" encoding="UTF-8"?>
<OTA_NotifReportRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="ACK-12345"
TimeStamp="2024-10-07T10:00:00"
Version="7.000">
<NotifDetails>
<HotelNotifReport>
<HotelReservations>
{reservations}
</HotelReservations>
</HotelNotifReport>
</NotifDetails>
</OTA_NotifReportRQ>"""
def create_notif_report_xml(self, unique_ids):
"""Create a notification report XML with given unique IDs."""
template = """<?xml version="1.0" encoding="UTF-8"?>
<OTA_NotifReportRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="ACK-12345"
TimeStamp="2024-10-07T10:00:00"
Version="7.000">
<NotifDetails>
<HotelNotifReport>
<HotelReservations>
{reservations}
</HotelReservations>
</HotelNotifReport>
</NotifDetails>
</OTA_NotifReportRQ>"""
reservations = ""
for unique_id in unique_ids:
reservations += f'<HotelReservation><UniqueID Type="14" ID="{unique_id}"/></HotelReservation>'
return template.format(reservations=reservations)
@pytest.mark.asyncio
async def test_setup_server_responds_to_read_requests(
self, alpinebits_server, populated_db_session, client_info, read_request_xml
):
"""Test 1: Setup AlpineBitsServer so that it can respond to sample read requests."""
# Send a read request and verify we get a response
response = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_request_xml,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
assert response is not None
assert response.status_code == HTTP_OK
assert response.xml_content is not None
# Verify response contains reservation data
assert "OTA_ResRetrieveRS" in response.xml_content
assert "HOTEL123" in response.xml_content
@pytest.mark.asyncio
async def test_send_acknowledgment_and_verify_response(
self, alpinebits_server, populated_db_session, client_info
):
"""Test 2: Send acknowledgment requests and verify responses."""
# First, get the unique IDs from a read request
read_xml = """<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="12345"
TimeStamp="2024-10-07T10:00:00"
Version="8.000">
<ReadRequests>
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort"/>
</ReadRequests>
</OTA_ReadRQ>"""
# Get reservations first
_read_response = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_xml,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
# Extract unique IDs from the response (we'll use test unique IDs)
test_unique_ids = [
"RES-2024-001",
"RES-2024-002",
] # In reality, these would be extracted from read response
# Create acknowledgment request
notif_report_xml = self.create_notif_report_xml(test_unique_ids)
# Send acknowledgment
ack_response = await alpinebits_server.handle_request(
request_action_name="OTA_NotifReport:GuestRequests",
request_xml=notif_report_xml,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
assert ack_response is not None
assert ack_response.status_code == HTTP_OK
assert "OTA_NotifReportRS" in ack_response.xml_content
@pytest.mark.asyncio
async def test_acknowledgments_recorded_in_database(
self, alpinebits_server, populated_db_session, client_info
):
"""Test 3: Verify that acknowledgments are recorded in the database."""
# Create acknowledgment request
test_unique_ids = ["test-ack-id-1", "test-ack-id-2"]
notif_report_xml = self.create_notif_report_xml(test_unique_ids)
# Count existing acked requests
result = await populated_db_session.execute(select(AckedRequest))
initial_count = len(result.all())
# Send acknowledgment
await alpinebits_server.handle_request(
request_action_name="OTA_NotifReport:GuestRequests",
request_xml=notif_report_xml,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
# Verify acknowledgments were recorded
result = await populated_db_session.execute(select(AckedRequest))
acked_requests = result.all()
assert len(acked_requests) == initial_count + 2
# Verify the specific acknowledgments
acked_ids = [req[0].unique_id for req in acked_requests]
assert "test-ack-id-1" in acked_ids
assert "test-ack-id-2" in acked_ids
# Verify client ID is recorded
for req in acked_requests[-2:]: # Last 2 requests
assert req[0].client_id == client_info.client_id
@pytest.mark.asyncio
async def test_read_excludes_acknowledged_reservations(
self, alpinebits_server, populated_db_session, client_info
):
"""Test 4: Verify that Read Requests no longer return already acknowledged reservations."""
# First read request - should return all reservations
read_xml = """<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="12345"
TimeStamp="2024-10-07T10:00:00"
Version="8.000">
<ReadRequests>
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort"/>
</ReadRequests>
</OTA_ReadRQ>"""
initial_response = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_xml,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
# Parse response to count initial reservations
parser = XmlParser()
initial_parsed = parser.from_string(
initial_response.xml_content, OtaResRetrieveRs
)
initial_count = 0
if (
initial_parsed.reservations_list
and initial_parsed.reservations_list.hotel_reservation
):
initial_count = len(initial_parsed.reservations_list.hotel_reservation)
# Acknowledge one reservation by using its MD5 hash
# Get the unique_id from sample reservation and create its MD5
sample_unique_id = "RES-2024-001"
md5_hash = hashlib.md5(sample_unique_id.encode()).hexdigest()
# Manually insert acknowledgment
acked_request = AckedRequest(
unique_id=md5_hash,
client_id=client_info.client_id,
timestamp=datetime.now(UTC),
)
populated_db_session.add(acked_request)
await populated_db_session.commit()
# Second read request - should return fewer reservations
second_response = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_xml,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
# Parse second response
second_parsed = parser.from_string(
second_response.xml_content, OtaResRetrieveRs
)
second_count = 0
if (
second_parsed.reservations_list
and second_parsed.reservations_list.hotel_reservation
):
second_count = len(second_parsed.reservations_list.hotel_reservation)
# Should have one fewer reservation
assert second_count == initial_count - 1
@pytest.mark.asyncio
async def test_acknowledgments_work_with_date_filters(
self,
alpinebits_server,
populated_db_session,
client_info,
read_request_xml_no_date_filter,
):
"""Test 5: Verify acknowledgments still work when SelectionCriteria date filters are applied."""
# Read request with date filter
read_xml_with_date = """<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="12345"
TimeStamp="2024-10-07T10:00:00"
Version="8.000">
<ReadRequests>
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort">
<SelectionCriteria Start="2024-12-01"/>
</HotelReadRequest>
</ReadRequests>
</OTA_ReadRQ>"""
# First read with date filter
initial_response = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_xml_with_date,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
parser = XmlParser()
initial_parsed = parser.from_string(
initial_response.xml_content, OtaResRetrieveRs
)
initial_count = 0
if (
initial_parsed.reservations_list
and initial_parsed.reservations_list.hotel_reservation
):
initial_count = len(initial_parsed.reservations_list.hotel_reservation)
assert initial_count > 0, "Initial count with date filter should be > 0"
assert initial_count == 1, (
"Should only return one reservation with this date filter"
)
# Acknowledge one reservation that falls within the date range
# The sample_reservation was created at 2024-11-01 and thus falls out of range
sample_unique_id = "RES-2024-002"
md5_hash = hashlib.md5(sample_unique_id.encode()).hexdigest()
acked_request = AckedRequest(
unique_id=md5_hash,
client_id=client_info.client_id,
timestamp=datetime.now(UTC),
)
populated_db_session.add(acked_request)
await populated_db_session.commit()
without_filter_read = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_request_xml_no_date_filter,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
without_filter_parsed = parser.from_string(
without_filter_read.xml_content, OtaResRetrieveRs
)
without_filter_count = 0
if (
without_filter_parsed.reservations_list
and without_filter_parsed.reservations_list.hotel_reservation
):
without_filter_count = len(
without_filter_parsed.reservations_list.hotel_reservation
)
assert without_filter_count == 1, (
"Without date filter, should return one reservation after acknowledgment"
)
# Second read with same date filter
second_response = await alpinebits_server.handle_request(
request_action_name="OTA_Read:GuestRequests",
request_xml=read_xml_with_date,
client_info=client_info,
version="2024-10",
dbsession=populated_db_session,
)
second_parsed = parser.from_string(
second_response.xml_content, OtaResRetrieveRs
)
second_count = 0
if (
second_parsed.reservations_list
and second_parsed.reservations_list.hotel_reservation
):
second_count = len(second_parsed.reservations_list.hotel_reservation)
# Should have exactly the same amount of reservations
assert second_count == initial_count, (
"Acknowledgment should not affect count when date filter is applied"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])