merge_db_fixes_to_main #16

Merged
jonas merged 40 commits from merge_db_fixes_to_main into main 2025-12-09 11:37:21 +00:00
4 changed files with 179 additions and 84 deletions
Showing only changes of commit c0e601e308 - Show all commits

View File

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

View File

@@ -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
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.
"""
async def create_reservation(
self, reservation_data: ReservationData, customer_data: CustomerData
):
"""Create a reservation with validated data.
# 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)
The data has already been validated by Pydantic before reaching here.
"""
from alpine_bits_python.db import Customer, Reservation
# 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)
# 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
# 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)
# 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)),
)
self.db_session.add(db_reservation)
await self.db_session.commit()
# 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)
return db_reservation, db_customer
# 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"
)
@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)
@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}

View File

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

View File

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