Fixed up the damm tests
This commit is contained in:
@@ -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
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user