Free rooms first implementation
This commit is contained in:
600
src/alpine_bits_python/free_rooms_action.py
Normal file
600
src/alpine_bits_python/free_rooms_action.py
Normal file
@@ -0,0 +1,600 @@
|
||||
"""Action handler for OTA_HotelInvCountNotif:FreeRooms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
|
||||
|
||||
from .alpinebits_server import (
|
||||
AlpineBitsAction,
|
||||
AlpineBitsActionName,
|
||||
AlpineBitsClientInfo,
|
||||
AlpineBitsResponse,
|
||||
Version,
|
||||
validate_hotel_authentication,
|
||||
)
|
||||
from .const import HttpStatusCode
|
||||
from .db import Hotel, HotelInventory, RoomAvailability
|
||||
from .generated import (
|
||||
ErrorType,
|
||||
InvCountCountType,
|
||||
OtaHotelInvCountNotifRq,
|
||||
OtaHotelInvCountNotifRs,
|
||||
UniqueIdInstance,
|
||||
)
|
||||
from .logging_config import get_logger
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
|
||||
SUPPORTED_CAPABILITIES = [
|
||||
"OTA_HotelInvCountNotif_accept_rooms",
|
||||
"OTA_HotelInvCountNotif_accept_categories",
|
||||
"OTA_HotelInvCountNotif_accept_deltas",
|
||||
"OTA_HotelInvCountNotif_accept_complete_set",
|
||||
"OTA_HotelInvCountNotif_accept_out_of_order",
|
||||
"OTA_HotelInvCountNotif_accept_out_of_market",
|
||||
"OTA_HotelInvCountNotif_accept_closing_seasons",
|
||||
]
|
||||
|
||||
CLOSING_SEASON_TYPE = "__CLOSE" # <= 8 chars per spec
|
||||
SOURCE_FREEROOMS = "FreeRooms"
|
||||
|
||||
COUNT_TYPE_MAP = {
|
||||
InvCountCountType.VALUE_2: "count_type_2",
|
||||
InvCountCountType.VALUE_6: "count_type_6",
|
||||
InvCountCountType.VALUE_9: "count_type_9",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FreeRoomsProcessingError(Exception):
|
||||
"""Custom exception that carries HTTP and OTA error metadata."""
|
||||
|
||||
message: str
|
||||
status_code: HttpStatusCode = HttpStatusCode.BAD_REQUEST
|
||||
error_type: ErrorType = ErrorType.VALUE_13
|
||||
code: str = "450"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class FreeRoomsAction(AlpineBitsAction):
|
||||
"""Handler for OTA_HotelInvCountNotif:FreeRooms requests."""
|
||||
|
||||
def __init__(self, config: dict | None = None):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_INV_COUNT_NOTIF_FREE_ROOMS
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config or {}
|
||||
self.supports = SUPPORTED_CAPABILITIES
|
||||
|
||||
self._parser = XmlParser()
|
||||
self._serializer = XmlSerializer(
|
||||
config=SerializerConfig(
|
||||
pretty_print=True,
|
||||
xml_declaration=True,
|
||||
encoding="UTF-8",
|
||||
)
|
||||
)
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request_xml: str,
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
dbsession: AsyncSession | None = None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Process FreeRooms inventory updates."""
|
||||
try:
|
||||
self._validate_action_name(action)
|
||||
|
||||
if request_xml is None:
|
||||
raise FreeRoomsProcessingError("Missing request payload")
|
||||
|
||||
if dbsession is None:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Database session unavailable",
|
||||
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
try:
|
||||
request = self._parser.from_string(request_xml, OtaHotelInvCountNotifRq)
|
||||
except Exception as exc: # pragma: no cover - serialization already tested upstream
|
||||
_LOGGER.exception("Failed to parse FreeRooms request: %s", exc)
|
||||
raise FreeRoomsProcessingError("Invalid XML payload") from exc
|
||||
|
||||
hotel_code = request.inventories.hotel_code if request.inventories else None
|
||||
if not hotel_code:
|
||||
raise FreeRoomsProcessingError("HotelCode attribute is required")
|
||||
|
||||
if not client_info or not client_info.username or not client_info.password:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Missing authentication context",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
error_type=ErrorType.VALUE_11,
|
||||
code="401",
|
||||
)
|
||||
|
||||
if not validate_hotel_authentication(
|
||||
client_info.username,
|
||||
client_info.password,
|
||||
hotel_code,
|
||||
self.config,
|
||||
):
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Unauthorized FreeRooms notification for hotel {hotel_code}",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
error_type=ErrorType.VALUE_11,
|
||||
code="401",
|
||||
)
|
||||
|
||||
hotel = await self._fetch_hotel(dbsession, hotel_code)
|
||||
if hotel is None:
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Hotel {hotel_code} is not provisioned on this server"
|
||||
)
|
||||
|
||||
is_complete_set = (
|
||||
request.unique_id is not None
|
||||
and request.unique_id.instance == UniqueIdInstance.COMPLETE_SET
|
||||
)
|
||||
update_type = "CompleteSet" if is_complete_set else "Delta"
|
||||
|
||||
inventory_cache: dict[tuple[str, str | None], HotelInventory] = {}
|
||||
|
||||
try:
|
||||
if is_complete_set:
|
||||
await self._process_complete_set(
|
||||
dbsession, hotel, request, update_type, inventory_cache
|
||||
)
|
||||
else:
|
||||
await self._process_delta(
|
||||
dbsession, hotel, request, update_type, inventory_cache
|
||||
)
|
||||
await dbsession.commit()
|
||||
except FreeRoomsProcessingError:
|
||||
await dbsession.rollback()
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
await dbsession.rollback()
|
||||
_LOGGER.exception("Unexpected FreeRooms failure: %s", exc)
|
||||
return self._error_response(
|
||||
"Internal server error while processing FreeRooms notification",
|
||||
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Processed FreeRooms %s update for hotel %s (%d inventory items)",
|
||||
update_type,
|
||||
hotel_code,
|
||||
len(request.inventories.inventory),
|
||||
)
|
||||
return self._success_response()
|
||||
except FreeRoomsProcessingError as exc:
|
||||
return self._error_response(
|
||||
exc.message,
|
||||
exc.status_code,
|
||||
error_type=exc.error_type,
|
||||
code=exc.code,
|
||||
)
|
||||
|
||||
def _validate_action_name(self, action: str) -> None:
|
||||
expected = self.name.value[1]
|
||||
if (action or "").strip() != expected:
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Invalid action {action}, expected {expected}",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
async def _fetch_hotel(self, session: AsyncSession, hotel_code: str) -> Hotel | None:
|
||||
stmt = select(Hotel).where(Hotel.hotel_id == hotel_code, Hotel.is_active.is_(True))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _process_complete_set(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel: Hotel,
|
||||
request: OtaHotelInvCountNotifRq,
|
||||
update_type: str,
|
||||
inventory_cache: dict[tuple[str, str | None], HotelInventory],
|
||||
) -> None:
|
||||
await self._delete_existing_availability(session, hotel.hotel_id)
|
||||
await self._process_inventories(
|
||||
session, hotel, request, update_type, inventory_cache, enforce_closing_order=True
|
||||
)
|
||||
|
||||
async def _process_delta(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel: Hotel,
|
||||
request: OtaHotelInvCountNotifRq,
|
||||
update_type: str,
|
||||
inventory_cache: dict[tuple[str, str | None], HotelInventory],
|
||||
) -> None:
|
||||
await self._process_inventories(
|
||||
session, hotel, request, update_type, inventory_cache, enforce_closing_order=False
|
||||
)
|
||||
|
||||
async def _delete_existing_availability(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel_id: str,
|
||||
) -> None:
|
||||
subquery = select(HotelInventory.id).where(HotelInventory.hotel_id == hotel_id)
|
||||
await session.execute(
|
||||
delete(RoomAvailability).where(RoomAvailability.inventory_id.in_(subquery))
|
||||
)
|
||||
|
||||
async def _process_inventories(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel: Hotel,
|
||||
request: OtaHotelInvCountNotifRq,
|
||||
update_type: str,
|
||||
inventory_cache: dict[tuple[str, str | None], HotelInventory],
|
||||
enforce_closing_order: bool,
|
||||
) -> None:
|
||||
inventories = request.inventories.inventory if request.inventories else []
|
||||
if not inventories:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Request must include at least one Inventory block",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
rows_to_upsert: list[dict[str, Any]] = []
|
||||
now = datetime.now(UTC)
|
||||
encountered_standard = False
|
||||
|
||||
for inventory in inventories:
|
||||
sac = inventory.status_application_control
|
||||
if sac is None:
|
||||
raise FreeRoomsProcessingError(
|
||||
"StatusApplicationControl element is required for each Inventory",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
is_closing = self._is_closing_season(sac)
|
||||
if is_closing:
|
||||
if inventory.inv_counts is not None:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Closing seasons cannot contain InvCounts data",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
if update_type != "CompleteSet":
|
||||
raise FreeRoomsProcessingError(
|
||||
"Closing seasons are only allowed on CompleteSet updates",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
if enforce_closing_order and encountered_standard:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Closing seasons must appear before other inventory entries",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
rows_to_upsert.extend(
|
||||
await self._process_closing_season(
|
||||
session, hotel, sac, update_type, now, inventory_cache
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
encountered_standard = True
|
||||
rows_to_upsert.extend(
|
||||
await self._process_inventory_item(
|
||||
session,
|
||||
hotel,
|
||||
sac,
|
||||
inventory.inv_counts,
|
||||
update_type,
|
||||
now,
|
||||
inventory_cache,
|
||||
)
|
||||
)
|
||||
|
||||
await self._upsert_availability_rows(session, rows_to_upsert)
|
||||
|
||||
async def _process_closing_season(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel: Hotel,
|
||||
sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl,
|
||||
update_type: str,
|
||||
timestamp: datetime,
|
||||
inventory_cache: dict[tuple[str, str | None], HotelInventory],
|
||||
) -> list[dict[str, Any]]:
|
||||
if sac.inv_type_code or sac.inv_code:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Closing season entries cannot specify InvTypeCode or InvCode",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
start_date, end_date = self._parse_date_range(sac.start, sac.end)
|
||||
inventory_item = await self._ensure_inventory_item(
|
||||
session,
|
||||
hotel.hotel_id,
|
||||
CLOSING_SEASON_TYPE,
|
||||
None,
|
||||
timestamp,
|
||||
inventory_cache,
|
||||
)
|
||||
|
||||
base_payload = {
|
||||
"inventory_id": inventory_item.id,
|
||||
"count_type_2": None,
|
||||
"count_type_6": None,
|
||||
"count_type_9": None,
|
||||
"is_closing_season": True,
|
||||
"last_updated": timestamp,
|
||||
"update_type": update_type,
|
||||
}
|
||||
|
||||
rows = []
|
||||
for day in self._iter_days(start_date, end_date):
|
||||
payload = dict(base_payload)
|
||||
payload["date"] = day
|
||||
rows.append(payload)
|
||||
return rows
|
||||
|
||||
async def _process_inventory_item(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel: Hotel,
|
||||
sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl,
|
||||
inv_counts: (
|
||||
OtaHotelInvCountNotifRq.Inventories.Inventory.InvCounts | None
|
||||
),
|
||||
update_type: str,
|
||||
timestamp: datetime,
|
||||
inventory_cache: dict[tuple[str, str | None], HotelInventory],
|
||||
) -> list[dict[str, Any]]:
|
||||
inv_type_code = (sac.inv_type_code or "").strip()
|
||||
if not inv_type_code:
|
||||
raise FreeRoomsProcessingError(
|
||||
"InvTypeCode is required unless AllInvCode=\"true\"",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
inv_code = sac.inv_code.strip() if sac.inv_code else None
|
||||
start_date, end_date = self._parse_date_range(sac.start, sac.end)
|
||||
|
||||
counts = self._extract_counts(inv_counts)
|
||||
base_counts = {
|
||||
"count_type_2": counts.get("count_type_2"),
|
||||
"count_type_6": counts.get("count_type_6"),
|
||||
"count_type_9": counts.get("count_type_9"),
|
||||
}
|
||||
|
||||
inventory_item = await self._ensure_inventory_item(
|
||||
session,
|
||||
hotel.hotel_id,
|
||||
inv_type_code,
|
||||
inv_code,
|
||||
timestamp,
|
||||
inventory_cache,
|
||||
)
|
||||
|
||||
base_payload = {
|
||||
"inventory_id": inventory_item.id,
|
||||
"is_closing_season": False,
|
||||
"last_updated": timestamp,
|
||||
"update_type": update_type,
|
||||
**base_counts,
|
||||
}
|
||||
|
||||
rows = []
|
||||
for day in self._iter_days(start_date, end_date):
|
||||
payload = dict(base_payload)
|
||||
payload["date"] = day
|
||||
rows.append(payload)
|
||||
return rows
|
||||
|
||||
def _parse_date_range(self, start_str: str, end_str: str) -> tuple[date, date]:
|
||||
try:
|
||||
start_date = date.fromisoformat(start_str)
|
||||
end_date = date.fromisoformat(end_str)
|
||||
except ValueError as exc:
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Invalid date format: {exc!s}",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
) from exc
|
||||
|
||||
if end_date < start_date:
|
||||
raise FreeRoomsProcessingError(
|
||||
"StatusApplicationControl End date cannot be before Start date",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
return start_date, end_date
|
||||
|
||||
def _iter_days(self, start_date: date, end_date: date):
|
||||
current = start_date
|
||||
while current <= end_date:
|
||||
yield current
|
||||
current += timedelta(days=1)
|
||||
|
||||
def _is_closing_season(
|
||||
self,
|
||||
sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl,
|
||||
) -> bool:
|
||||
return (sac.all_inv_code or "").strip().lower() == "true"
|
||||
|
||||
def _extract_counts(
|
||||
self,
|
||||
inv_counts: OtaHotelInvCountNotifRq.Inventories.Inventory.InvCounts | None,
|
||||
) -> dict[str, int | None]:
|
||||
if inv_counts is None or not inv_counts.inv_count:
|
||||
return {}
|
||||
|
||||
parsed: dict[str, int] = {}
|
||||
for count in inv_counts.inv_count:
|
||||
column_name = COUNT_TYPE_MAP.get(count.count_type)
|
||||
if column_name is None:
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Unsupported CountType {count.count_type}",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if column_name in parsed:
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Duplicate CountType {count.count_type.value} detected",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
value = int(count.count)
|
||||
except ValueError as exc:
|
||||
raise FreeRoomsProcessingError(
|
||||
f"Invalid Count value '{count.count}'",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
) from exc
|
||||
|
||||
if value < 0:
|
||||
raise FreeRoomsProcessingError(
|
||||
"Count values must be non-negative",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
parsed[column_name] = value
|
||||
|
||||
return parsed
|
||||
|
||||
async def _ensure_inventory_item(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
hotel_id: str,
|
||||
inv_type_code: str,
|
||||
inv_code: str | None,
|
||||
timestamp: datetime,
|
||||
cache: dict[tuple[str, str | None], HotelInventory],
|
||||
) -> HotelInventory:
|
||||
cache_key = (inv_type_code, inv_code)
|
||||
if cache_key in cache:
|
||||
return cache[cache_key]
|
||||
|
||||
filters = [
|
||||
HotelInventory.hotel_id == hotel_id,
|
||||
HotelInventory.inv_type_code == inv_type_code,
|
||||
]
|
||||
if inv_code is None:
|
||||
filters.append(HotelInventory.inv_code.is_(None))
|
||||
else:
|
||||
filters.append(HotelInventory.inv_code == inv_code)
|
||||
|
||||
stmt = select(HotelInventory).where(*filters)
|
||||
result = await session.execute(stmt)
|
||||
inventory_item = result.scalar_one_or_none()
|
||||
|
||||
if inventory_item:
|
||||
inventory_item.last_updated = timestamp
|
||||
else:
|
||||
inventory_item = HotelInventory(
|
||||
hotel_id=hotel_id,
|
||||
inv_type_code=inv_type_code,
|
||||
inv_code=inv_code,
|
||||
source=SOURCE_FREEROOMS,
|
||||
first_seen=timestamp,
|
||||
last_updated=timestamp,
|
||||
)
|
||||
session.add(inventory_item)
|
||||
await session.flush()
|
||||
|
||||
cache[cache_key] = inventory_item
|
||||
return inventory_item
|
||||
|
||||
async def _upsert_availability_rows(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
if not rows:
|
||||
return
|
||||
|
||||
bind = session.get_bind()
|
||||
dialect_name = bind.dialect.name if bind else ""
|
||||
table = RoomAvailability.__table__
|
||||
|
||||
if dialect_name == "postgresql":
|
||||
stmt = pg_insert(table).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["inventory_id", "date"],
|
||||
set_=self._build_upsert_set(stmt),
|
||||
)
|
||||
await session.execute(stmt)
|
||||
return
|
||||
|
||||
if dialect_name == "sqlite":
|
||||
stmt = sqlite_insert(table).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["inventory_id", "date"],
|
||||
set_=self._build_upsert_set(stmt),
|
||||
)
|
||||
await session.execute(stmt)
|
||||
return
|
||||
|
||||
await self._upsert_with_fallback(session, rows)
|
||||
|
||||
def _build_upsert_set(self, stmt):
|
||||
return {
|
||||
"count_type_2": stmt.excluded.count_type_2,
|
||||
"count_type_6": stmt.excluded.count_type_6,
|
||||
"count_type_9": stmt.excluded.count_type_9,
|
||||
"is_closing_season": stmt.excluded.is_closing_season,
|
||||
"last_updated": stmt.excluded.last_updated,
|
||||
"update_type": stmt.excluded.update_type,
|
||||
}
|
||||
|
||||
async def _upsert_with_fallback(
|
||||
self, session: AsyncSession, rows: list[dict[str, Any]]
|
||||
) -> None:
|
||||
for row in rows:
|
||||
stmt = select(RoomAvailability).where(
|
||||
RoomAvailability.inventory_id == row["inventory_id"],
|
||||
RoomAvailability.date == row["date"],
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
existing.count_type_2 = row["count_type_2"]
|
||||
existing.count_type_6 = row["count_type_6"]
|
||||
existing.count_type_9 = row["count_type_9"]
|
||||
existing.is_closing_season = row["is_closing_season"]
|
||||
existing.last_updated = row["last_updated"]
|
||||
existing.update_type = row["update_type"]
|
||||
else:
|
||||
session.add(RoomAvailability(**row))
|
||||
|
||||
def _success_response(self) -> AlpineBitsResponse:
|
||||
response = OtaHotelInvCountNotifRs(version="7.000", success="")
|
||||
xml = self._serializer.render(
|
||||
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
return AlpineBitsResponse(xml, HttpStatusCode.OK)
|
||||
|
||||
def _error_response(
|
||||
self,
|
||||
message: str,
|
||||
status_code: HttpStatusCode,
|
||||
error_type: ErrorType = ErrorType.VALUE_13,
|
||||
code: str = "450",
|
||||
) -> AlpineBitsResponse:
|
||||
error = OtaHotelInvCountNotifRs.Errors.Error(
|
||||
type_value=error_type,
|
||||
code=code,
|
||||
content=[message],
|
||||
)
|
||||
errors = OtaHotelInvCountNotifRs.Errors(error=[error])
|
||||
response = OtaHotelInvCountNotifRs(version="7.000", errors=errors)
|
||||
xml = self._serializer.render(
|
||||
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
return AlpineBitsResponse(xml, status_code)
|
||||
Reference in New Issue
Block a user