Fixed some tests and added schemas

This commit is contained in:
Jonas Linter
2025-12-01 10:14:14 +01:00
parent 3e577a499f
commit 2be10ff899
4 changed files with 399 additions and 27 deletions

View File

@@ -32,6 +32,8 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from alpine_bits_python.schemas import WebhookRequestData
from .alpinebits_server import (
AlpineBitsActionName,
AlpineBitsClientInfo,
@@ -888,8 +890,9 @@ async def handle_webhook_unified(
webhook_request.status = WebhookStatus.PROCESSING
webhook_request.processing_started_at = timestamp
else:
# 5. Create new webhook_request
webhook_request = WebhookRequest(
webhook_request_data = WebhookRequestData(
payload_hash=payload_hash,
webhook_endpoint_id=webhook_endpoint.id,
hotel_id=webhook_endpoint.hotel_id,
@@ -900,6 +903,9 @@ async def handle_webhook_unified(
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
# 5. Create new webhook_request
webhook_request = WebhookRequest(**webhook_request_data.model_dump())
db_session.add(webhook_request)
await db_session.flush()

View File

@@ -10,11 +10,15 @@ from XML generation (xsdata) follows clean architecture principles.
"""
import hashlib
import json
from datetime import date, datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
from .const import WebhookStatus
# Country name to ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = {
@@ -308,6 +312,148 @@ class CommentsData(BaseModel):
model_config = {"from_attributes": True}
class HotelData(BaseModel):
"""Validated hotel configuration data."""
hotel_id: str = Field(..., min_length=1, max_length=50)
hotel_name: str = Field(..., min_length=1, max_length=200)
username: str = Field(..., min_length=1, max_length=100)
password_hash: str = Field(..., min_length=1, max_length=200)
meta_account_id: str | None = Field(None, max_length=50)
google_account_id: str | None = Field(None, max_length=50)
push_endpoint_url: str | None = Field(None, max_length=500)
push_endpoint_token: str | None = Field(None, max_length=200)
push_endpoint_username: str | None = Field(None, max_length=100)
created_at: datetime = Field(default_factory=lambda: datetime.now())
updated_at: datetime = Field(default_factory=lambda: datetime.now())
is_active: bool = Field(default=True)
@field_validator("hotel_id", "hotel_name", "username")
@classmethod
def strip_whitespace(cls, v: str) -> str:
"""Remove leading/trailing whitespace."""
return v.strip()
model_config = {"from_attributes": True}
class WebhookEndpointData(BaseModel):
"""Validated webhook endpoint configuration data."""
hotel_id: str = Field(..., min_length=1, max_length=50)
webhook_secret: str = Field(..., min_length=1, max_length=64)
webhook_type: str = Field(..., min_length=1, max_length=50)
description: str | None = Field(None, max_length=200)
is_enabled: bool = Field(default=True)
created_at: datetime = Field(default_factory=lambda: datetime.now())
@field_validator("hotel_id", "webhook_secret", "webhook_type")
@classmethod
def strip_whitespace(cls, v: str) -> str:
"""Remove leading/trailing whitespace."""
return v.strip()
model_config = {"from_attributes": True}
class WebhookRequestData(BaseModel):
"""Validated webhook request data.
This model handles the special case where:
- payload_json is required for creation (to calculate payload_hash)
- payload_json becomes optional after processing (can be purged for privacy/storage)
- payload_hash is auto-calculated from payload_json when provided
"""
# Required fields
payload_json: dict[str, Any] | None = Field(
...,
description="Webhook payload (required for creation, nullable after purge)"
)
# Auto-calculated from payload_json
payload_hash: str | None = Field(
None,
min_length=64,
max_length=64,
description="SHA256 hash of canonical JSON payload (auto-calculated)"
)
# Optional foreign keys
webhook_endpoint_id: int | None = Field(None, gt=0)
hotel_id: str | None = Field(None, max_length=50)
# Processing tracking
status: WebhookStatus = Field(default=WebhookStatus.PENDING)
processing_started_at: datetime | None = None
processing_completed_at: datetime | None = None
# Retry handling
retry_count: int = Field(default=0, ge=0)
last_error: str | None = Field(None, max_length=2000)
# Payload metadata
purged_at: datetime | None = None
# Request metadata
created_at: datetime = Field(default_factory=lambda: datetime.now())
source_ip: str | None = Field(None, max_length=45)
user_agent: str | None = Field(None, max_length=500)
# Result tracking
created_customer_id: int | None = Field(None, gt=0)
created_reservation_id: int | None = Field(None, gt=0)
@model_validator(mode="after")
def calculate_payload_hash(self) -> "WebhookRequestData":
"""Auto-calculate payload_hash from payload_json if not provided.
Uses the same hashing algorithm as api.py:
- Canonical JSON with sorted keys
- UTF-8 encoding
- SHA256 hash
This runs after all field validation, so we can access the validated payload_json.
"""
# Only calculate if payload_json is provided and payload_hash is not set
if self.payload_json is not None and self.payload_hash is None:
# Create canonical JSON string (sorted keys for consistency)
payload_json_str = json.dumps(self.payload_json, sort_keys=True)
# Calculate SHA256 hash
self.payload_hash = hashlib.sha256(
payload_json_str.encode("utf-8")
).hexdigest()
return self
@model_validator(mode="after")
def validate_payload_hash_requirements(self) -> "WebhookRequestData":
"""Ensure payload_hash is present (either provided or calculated).
This validator runs after calculate_payload_hash, so payload_hash should
be set if payload_json was provided.
"""
if self.payload_hash is None:
raise ValueError(
"payload_hash is required. It can be auto-calculated from payload_json "
"or explicitly provided."
)
return self
@field_validator("status", mode="before")
@classmethod
def normalize_status(cls, v: str | WebhookStatus) -> WebhookStatus:
"""Normalize status to WebhookStatus enum."""
if isinstance(v, WebhookStatus):
return v
if isinstance(v, str):
return WebhookStatus(v)
raise ValueError(f"Invalid webhook status: {v}")
model_config = {"from_attributes": True}
# Example usage in a service layer
class ReservationService:
"""Example service showing how to use Pydantic models with SQLAlchemy."""