merge_db_fixes_to_main #16
@@ -21,7 +21,7 @@ from .db import (
|
||||
SessionMaker,
|
||||
)
|
||||
from .logging_config import get_logger
|
||||
from .schemas import ConversionGuestData
|
||||
from .schemas import ConversionData, ConversionGuestData
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
|
||||
@@ -552,7 +552,7 @@ class ConversionService:
|
||||
pms_reservation_id if successfully created/updated, None if error occurred
|
||||
|
||||
"""
|
||||
pms_reservation_id = reservation_elem.get("id")
|
||||
pms_reservation_id = int(reservation_elem.get("id"))
|
||||
|
||||
async with semaphore:
|
||||
# In concurrent mode, create a new session for this task
|
||||
@@ -652,9 +652,15 @@ class ConversionService:
|
||||
"daily_sales_count": 0,
|
||||
}
|
||||
|
||||
# Extract reservation metadata
|
||||
hotel_id = reservation_elem.get("hotelID")
|
||||
pms_reservation_id = reservation_elem.get("id")
|
||||
try:
|
||||
# Extract reservation metadata
|
||||
|
||||
pms_reservation_id = int(reservation_elem.get("id"))
|
||||
except ValueError as e:
|
||||
_LOGGER.error("Invalid reservation metadata in reservation element: %s", e)
|
||||
return stats
|
||||
|
||||
reservation_number = reservation_elem.get("number")
|
||||
reservation_date_str = reservation_elem.get("date")
|
||||
creation_time_str = reservation_elem.get("creationTime")
|
||||
@@ -751,11 +757,8 @@ class ConversionService:
|
||||
else:
|
||||
# Create new conversion entry (without matching - will be done later)
|
||||
# Note: Guest information (first_name, last_name, email, etc) is stored in ConversionGuest table
|
||||
conversion = Conversion(
|
||||
conversion_data = ConversionData(
|
||||
# Links to existing entities (nullable, will be filled in after matching)
|
||||
reservation_id=None,
|
||||
customer_id=None,
|
||||
hashed_customer_id=None,
|
||||
# Reservation metadata
|
||||
hotel_id=hotel_id,
|
||||
guest_id=guest_id, # Links to ConversionGuest
|
||||
@@ -770,9 +773,8 @@ class ConversionService:
|
||||
advertising_partner=advertising_partner,
|
||||
advertising_campagne=advertising_campagne,
|
||||
# Metadata
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
conversion = Conversion(**conversion_data.model_dump())
|
||||
session.add(conversion)
|
||||
_LOGGER.debug(
|
||||
"Created conversion (pms_id=%s)",
|
||||
@@ -1503,7 +1505,7 @@ class ConversionService:
|
||||
|
||||
async def _match_conversion_from_db_safe(
|
||||
self,
|
||||
pms_reservation_id: str,
|
||||
pms_reservation_id: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
stats: dict[str, int],
|
||||
) -> None:
|
||||
|
||||
@@ -11,7 +11,7 @@ from XML generation (xsdata) follows clean architecture principles.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -20,6 +20,35 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validato
|
||||
from .const import WebhookStatus
|
||||
|
||||
|
||||
# Generalized integer validator for reuse across models
|
||||
def convert_to_int(field_name: str, v: Any) -> int:
|
||||
"""Convert a value to integer, handling string inputs.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field being validated (for error messages)
|
||||
v: Value to convert (can be int, str, or None)
|
||||
|
||||
Returns:
|
||||
Integer value
|
||||
|
||||
Raises:
|
||||
ValueError: If value is None or cannot be converted to int
|
||||
|
||||
"""
|
||||
if v is None:
|
||||
msg = f"{field_name} cannot be None"
|
||||
raise ValueError(msg)
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError as e:
|
||||
msg = f"{field_name} must be a valid integer, got: {v}"
|
||||
raise ValueError(msg) from e
|
||||
msg = f"{field_name} must be int or str, got: {type(v)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Country name to ISO 3166-1 alpha-2 code mapping
|
||||
COUNTRY_NAME_TO_CODE = {
|
||||
# English names
|
||||
@@ -195,6 +224,7 @@ class CustomerData(BaseModel):
|
||||
|
||||
Returns:
|
||||
2-letter ISO country code (uppercase) or None if input is None/empty
|
||||
|
||||
"""
|
||||
if not v:
|
||||
return None
|
||||
@@ -367,8 +397,7 @@ class WebhookRequestData(BaseModel):
|
||||
|
||||
# Required fields
|
||||
payload_json: dict[str, Any] | None = Field(
|
||||
...,
|
||||
description="Webhook payload (required for creation, nullable after purge)"
|
||||
..., description="Webhook payload (required for creation, nullable after purge)"
|
||||
)
|
||||
|
||||
# Auto-calculated from payload_json
|
||||
@@ -376,7 +405,7 @@ class WebhookRequestData(BaseModel):
|
||||
None,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
description="SHA256 hash of canonical JSON payload (auto-calculated)"
|
||||
description="SHA256 hash of canonical JSON payload (auto-calculated)",
|
||||
)
|
||||
|
||||
# Optional foreign keys
|
||||
@@ -517,49 +546,71 @@ class ConversionGuestData(BaseModel):
|
||||
@classmethod
|
||||
def convert_guest_id_to_int(cls, v: Any) -> int:
|
||||
"""Convert guest_id to integer (handles string input from XML)."""
|
||||
if v is None:
|
||||
raise ValueError("guest_id cannot be None")
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"guest_id must be a valid integer, got: {v}") from e
|
||||
raise ValueError(f"guest_id must be int or str, got: {type(v)}")
|
||||
return convert_to_int("guest_id", v)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ReservationService:
|
||||
"""Example service showing how to use Pydantic models with SQLAlchemy."""
|
||||
class ConversionData(BaseModel):
|
||||
"""Validated conversion data from PMS XML.
|
||||
|
||||
def __init__(self, db_session):
|
||||
self.db_session = db_session
|
||||
|
||||
async def create_reservation(
|
||||
self, reservation_data: ReservationData, customer_data: CustomerData
|
||||
):
|
||||
"""Create a reservation with validated data.
|
||||
|
||||
The data has already been validated by Pydantic before reaching here.
|
||||
Handles validation for conversion records extracted from
|
||||
hotel PMS conversion XML files. This model ensures proper type conversion
|
||||
and validation before creating a Conversion database entry.
|
||||
"""
|
||||
from alpine_bits_python.db import Customer, Reservation
|
||||
|
||||
# Convert validated Pydantic model to SQLAlchemy model
|
||||
db_customer = Customer(**customer_data.model_dump(exclude_none=True))
|
||||
self.db_session.add(db_customer)
|
||||
await self.db_session.flush() # Get the customer ID
|
||||
# Foreign key references (nullable - matched after creation)
|
||||
reservation_id: int | None = Field(None, gt=0)
|
||||
customer_id: int | None = Field(None, gt=0)
|
||||
hashed_customer_id: int | None = Field(None, gt=0)
|
||||
|
||||
# Create reservation linked to customer
|
||||
db_reservation = Reservation(
|
||||
customer_id=db_customer.id,
|
||||
**reservation_data.model_dump(
|
||||
exclude={"children_ages"}
|
||||
), # Handle separately
|
||||
children_ages=",".join(map(str, reservation_data.children_ages)),
|
||||
# Required reservation metadata from PMS
|
||||
hotel_id: str = Field(..., min_length=1, max_length=50)
|
||||
pms_reservation_id: int = Field(..., gt=0)
|
||||
guest_id: int | None = Field(None, gt=0)
|
||||
|
||||
# Optional reservation metadata
|
||||
reservation_number: str | None = Field(None, max_length=100)
|
||||
reservation_date: date | None = None
|
||||
creation_time: datetime | None = None
|
||||
reservation_type: str | None = Field(None, max_length=50)
|
||||
booking_channel: str | None = Field(None, max_length=100)
|
||||
|
||||
# Advertising/tracking data (used for matching)
|
||||
advertising_medium: str | None = Field(None, max_length=200)
|
||||
advertising_partner: str | None = Field(None, max_length=200)
|
||||
advertising_campagne: str | None = Field(None, max_length=500)
|
||||
|
||||
# Attribution flags
|
||||
directly_attributable: bool = Field(default=False)
|
||||
guest_matched: bool = Field(default=False)
|
||||
|
||||
# Timestamps (auto-managed)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
@field_validator(
|
||||
"pms_reservation_id", "guest_id", "reservation_id", "customer_id",
|
||||
"hashed_customer_id", mode="before"
|
||||
)
|
||||
self.db_session.add(db_reservation)
|
||||
await self.db_session.commit()
|
||||
@classmethod
|
||||
def convert_int_fields(cls, v: Any) -> int | None:
|
||||
"""Convert integer fields from string to int (handles XML input)."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
# Get the field name from the validation context if available
|
||||
# For now, use a generic name since we handle multiple fields
|
||||
return convert_to_int("field", v)
|
||||
|
||||
return db_reservation, db_customer
|
||||
@field_validator("hotel_id", "reservation_number", "reservation_type",
|
||||
"booking_channel", "advertising_medium", "advertising_partner",
|
||||
"advertising_campagne", mode="before")
|
||||
@classmethod
|
||||
def strip_string_fields(cls, v: str | None) -> str | None:
|
||||
"""Strip whitespace from string fields."""
|
||||
if v is None:
|
||||
return None
|
||||
stripped = str(v).strip()
|
||||
return stripped if stripped else None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -9,6 +9,43 @@ from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
def validate_and_convert_id(field_name: str, value: str | int) -> str:
|
||||
"""Validate that an ID field is convertible to integer and return as string.
|
||||
|
||||
This helper ensures ID fields (like reservation_id, guest_id) are valid integers,
|
||||
which is important since the Pydantic models will convert them from strings to ints.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field for error messages
|
||||
value: The ID value (can be string or int)
|
||||
|
||||
Returns:
|
||||
String representation of the validated integer ID
|
||||
|
||||
Raises:
|
||||
ValueError: If value cannot be converted to a valid positive integer
|
||||
|
||||
"""
|
||||
def _raise_invalid_type_error():
|
||||
"""Raise error for invalid ID type."""
|
||||
msg = (
|
||||
f"{field_name} must be convertible to a positive integer, "
|
||||
f"got: {value!r} (type: {type(value).__name__})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
# Convert to int first to validate it's a valid integer
|
||||
int_value = int(value)
|
||||
if int_value <= 0:
|
||||
msg = f"{field_name} must be a positive integer, got: {value}"
|
||||
raise ValueError(msg)
|
||||
# Return as string for XML attributes
|
||||
return str(int_value)
|
||||
except (ValueError, TypeError):
|
||||
_raise_invalid_type_error()
|
||||
|
||||
|
||||
class RoomReservationBuilder:
|
||||
"""Builder for creating roomReservation XML elements with daily sales."""
|
||||
|
||||
@@ -133,7 +170,7 @@ class ReservationXMLBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
hotel_id: str,
|
||||
reservation_id: str,
|
||||
reservation_id: str | int,
|
||||
reservation_number: str,
|
||||
reservation_date: str,
|
||||
creation_time: Optional[str] = None,
|
||||
@@ -146,7 +183,7 @@ class ReservationXMLBuilder:
|
||||
|
||||
Args:
|
||||
hotel_id: Hotel ID
|
||||
reservation_id: Reservation ID
|
||||
reservation_id: Reservation ID (must be convertible to positive integer)
|
||||
reservation_number: Reservation number
|
||||
reservation_date: Reservation date in YYYY-MM-DD format
|
||||
creation_time: Creation timestamp (defaults to reservation_date + T00:00:00)
|
||||
@@ -156,7 +193,7 @@ class ReservationXMLBuilder:
|
||||
advertising_campagne: Advertising campaign
|
||||
"""
|
||||
self.hotel_id = hotel_id
|
||||
self.reservation_id = reservation_id
|
||||
self.reservation_id = validate_and_convert_id("reservation_id", reservation_id)
|
||||
self.reservation_number = reservation_number
|
||||
self.reservation_date = reservation_date
|
||||
self.creation_time = creation_time or f"{reservation_date}T00:00:00"
|
||||
@@ -170,7 +207,7 @@ class ReservationXMLBuilder:
|
||||
|
||||
def set_guest(
|
||||
self,
|
||||
guest_id: str,
|
||||
guest_id: str | int,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
email: str,
|
||||
@@ -182,7 +219,7 @@ class ReservationXMLBuilder:
|
||||
"""Set guest information for the reservation.
|
||||
|
||||
Args:
|
||||
guest_id: Guest ID
|
||||
guest_id: Guest ID (must be convertible to positive integer)
|
||||
first_name: Guest first name
|
||||
last_name: Guest last name
|
||||
email: Guest email
|
||||
@@ -194,8 +231,9 @@ class ReservationXMLBuilder:
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
validated_guest_id = validate_and_convert_id("guest_id", guest_id)
|
||||
self.guest_data = {
|
||||
"id": guest_id,
|
||||
"id": validated_guest_id,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"email": email,
|
||||
|
||||
@@ -372,13 +372,13 @@ class TestConversionServiceWithImportedData:
|
||||
res1_v1 = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="res_001",
|
||||
reservation_number="RES-001",
|
||||
reservation_id="100",
|
||||
reservation_number="100",
|
||||
reservation_date="2025-11-14",
|
||||
reservation_type="request",
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_001",
|
||||
guest_id="100",
|
||||
first_name="Alice",
|
||||
last_name="Johnson",
|
||||
email="alice@example.com",
|
||||
@@ -397,13 +397,13 @@ class TestConversionServiceWithImportedData:
|
||||
res2_v1 = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="res_002",
|
||||
reservation_number="RES-002",
|
||||
reservation_id="101",
|
||||
reservation_number="101",
|
||||
reservation_date="2025-11-15",
|
||||
reservation_type="reservation",
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_002",
|
||||
guest_id="101",
|
||||
first_name="Bob",
|
||||
last_name="Smith",
|
||||
email="bob@example.com",
|
||||
@@ -446,13 +446,13 @@ class TestConversionServiceWithImportedData:
|
||||
res1_v2 = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="res_001", # Same ID
|
||||
reservation_number="RES-001", # Same number
|
||||
reservation_id="100", # Same ID
|
||||
reservation_number="100", # Same number
|
||||
reservation_date="2025-11-14",
|
||||
reservation_type="reservation", # Changed from request
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_001",
|
||||
guest_id="100",
|
||||
first_name="Alice",
|
||||
last_name="Johnson",
|
||||
email="alice@example.com",
|
||||
@@ -471,13 +471,13 @@ class TestConversionServiceWithImportedData:
|
||||
res2_v2 = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="res_002", # Same ID
|
||||
reservation_number="RES-002", # Same number
|
||||
reservation_id="101", # Same ID
|
||||
reservation_number="101", # Same number
|
||||
reservation_date="2025-11-15",
|
||||
reservation_type="request", # Changed from reservation
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_002",
|
||||
guest_id="101",
|
||||
first_name="Bob",
|
||||
last_name="Smith",
|
||||
email="bob@example.com",
|
||||
@@ -554,12 +554,12 @@ class TestXMLBuilderUsage:
|
||||
xml_content = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="test_123",
|
||||
reservation_number="RES-123",
|
||||
reservation_id="123",
|
||||
reservation_number="123",
|
||||
reservation_date="2025-11-14",
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_001",
|
||||
guest_id="157",
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
email="john@example.com",
|
||||
@@ -591,12 +591,12 @@ class TestXMLBuilderUsage:
|
||||
xml_content = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="test_456",
|
||||
reservation_number="RES-456",
|
||||
reservation_id="456",
|
||||
reservation_number="456",
|
||||
reservation_date="2025-11-14",
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_002",
|
||||
guest_id="157",
|
||||
first_name="Jane",
|
||||
last_name="Smith",
|
||||
email="jane@example.com",
|
||||
@@ -634,12 +634,12 @@ class TestXMLBuilderUsage:
|
||||
res1 = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="test_001",
|
||||
reservation_number="RES-001",
|
||||
reservation_id="175",
|
||||
reservation_number="175",
|
||||
reservation_date="2025-11-14",
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_001",
|
||||
guest_id="157",
|
||||
first_name="Alice",
|
||||
last_name="Johnson",
|
||||
email="alice@example.com",
|
||||
@@ -656,12 +656,12 @@ class TestXMLBuilderUsage:
|
||||
res2 = (
|
||||
ReservationXMLBuilder(
|
||||
hotel_id="39054_001",
|
||||
reservation_id="test_002",
|
||||
reservation_id="2725",
|
||||
reservation_number="RES-002",
|
||||
reservation_date="2025-11-15",
|
||||
)
|
||||
.set_guest(
|
||||
guest_id="guest_002",
|
||||
guest_id="2525",
|
||||
first_name="Bob",
|
||||
last_name="Williams",
|
||||
email="bob@example.com",
|
||||
@@ -752,10 +752,12 @@ class TestHashedMatchingLogic:
|
||||
test_db_session.add(reservation)
|
||||
await test_db_session.commit()
|
||||
|
||||
PMS_RESERVATION_ID = 157
|
||||
|
||||
# Create conversion XML with matching hashed data
|
||||
xml_content = """<?xml version="1.0"?>
|
||||
xml_content = f"""<?xml version="1.0"?>
|
||||
<root>
|
||||
<reservation id="pms_123" hotelID="hotel_1" number="RES001" date="2025-01-15">
|
||||
<reservation id="{PMS_RESERVATION_ID}" hotelID="hotel_1" number="378" date="2025-01-15">
|
||||
<guest id="123" firstName="David" lastName="Miller" email="david@example.com"/>
|
||||
<roomReservations>
|
||||
<roomReservation roomNumber="101" arrival="2025-01-15" departure="2025-01-17" status="confirmed">
|
||||
@@ -772,7 +774,9 @@ class TestHashedMatchingLogic:
|
||||
|
||||
# Verify conversion was created
|
||||
result = await test_db_session.execute(
|
||||
select(Conversion).where(Conversion.pms_reservation_id == "pms_123")
|
||||
select(Conversion).where(
|
||||
Conversion.pms_reservation_id == PMS_RESERVATION_ID
|
||||
)
|
||||
)
|
||||
conversion = result.scalar_one_or_none()
|
||||
|
||||
@@ -785,7 +789,7 @@ class TestHashedMatchingLogic:
|
||||
|
||||
result_with_guest = await test_db_session.execute(
|
||||
select(Conversion)
|
||||
.where(Conversion.pms_reservation_id == "pms_123")
|
||||
.where(Conversion.pms_reservation_id == PMS_RESERVATION_ID)
|
||||
.options(selectinload(Conversion.guest))
|
||||
)
|
||||
conversion_with_guest = result_with_guest.scalar_one_or_none()
|
||||
|
||||
Reference in New Issue
Block a user