Fixed up the damm tests

This commit is contained in:
Jonas Linter
2025-12-02 15:24:30 +01:00
parent 0f3805bed4
commit 473becfe5b
4 changed files with 179 additions and 84 deletions

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}