- Introduced Hotel and WebhookEndpoint models to manage hotel configurations and webhook settings. - Implemented sync_config_to_database function to synchronize hotel data from configuration to the database. - Added HotelService for accessing hotel configurations and managing customer data. - Created WebhookProcessor interface and specific processors for handling different webhook types (Wix form and generic). - Enhanced webhook processing logic to handle incoming requests and create/update reservations and customers. - Added logging for better traceability of operations related to hotels and webhooks.
434 lines
14 KiB
Python
434 lines
14 KiB
Python
"""Webhook processor interface and implementations."""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from datetime import datetime
|
|
from typing import Any, Protocol
|
|
|
|
from fastapi import HTTPException, Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from alpine_bits_python.api import _LOGGER, get_advertising_account_ids
|
|
from alpine_bits_python.auth import generate_unique_id
|
|
from alpine_bits_python.customer_service import CustomerService
|
|
from alpine_bits_python.reservation_service import ReservationService
|
|
from alpine_bits_python.schemas import ReservationData
|
|
|
|
from .db import Hotel, WebhookRequest
|
|
from .logging_config import get_logger
|
|
|
|
_LOGGER = get_logger(__name__)
|
|
|
|
|
|
class WebhookProcessorProtocol(Protocol):
|
|
"""Protocol for webhook processors."""
|
|
|
|
@property
|
|
def webhook_type(self) -> str:
|
|
"""Return webhook type identifier (e.g., 'wix_form', 'generic')."""
|
|
...
|
|
|
|
async def process(
|
|
self,
|
|
payload: dict[str, Any],
|
|
webhook_request: WebhookRequest,
|
|
hotel: Hotel,
|
|
db_session: AsyncSession,
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Process webhook payload.
|
|
|
|
Args:
|
|
payload: Parsed webhook payload
|
|
webhook_request: WebhookRequest database record
|
|
hotel: Hotel associated with this webhook
|
|
db_session: Database session
|
|
request: FastAPI Request object
|
|
|
|
Returns:
|
|
Response dict with status, message, customer_id, reservation_id
|
|
|
|
Raises:
|
|
HTTPException on processing errors
|
|
"""
|
|
...
|
|
|
|
|
|
class WebhookProcessorRegistry:
|
|
"""Registry for webhook processors."""
|
|
|
|
def __init__(self):
|
|
"""Initialize the registry."""
|
|
self._processors: dict[str, WebhookProcessorProtocol] = {}
|
|
|
|
def register(self, processor: WebhookProcessorProtocol) -> None:
|
|
"""Register a webhook processor.
|
|
|
|
Args:
|
|
processor: Processor instance to register
|
|
"""
|
|
self._processors[processor.webhook_type] = processor
|
|
_LOGGER.info("Registered webhook processor: %s", processor.webhook_type)
|
|
|
|
def get_processor(self, webhook_type: str) -> WebhookProcessorProtocol | None:
|
|
"""Get processor for webhook type.
|
|
|
|
Args:
|
|
webhook_type: Type of webhook to process
|
|
|
|
Returns:
|
|
Processor instance or None if not found
|
|
"""
|
|
return self._processors.get(webhook_type)
|
|
|
|
|
|
class WixFormProcessor:
|
|
"""Processor for Wix form webhooks."""
|
|
|
|
@property
|
|
def webhook_type(self) -> str:
|
|
"""Return webhook type identifier."""
|
|
return "wix_form"
|
|
|
|
async def process(
|
|
self,
|
|
payload: dict[str, Any],
|
|
webhook_request: WebhookRequest,
|
|
hotel: Hotel,
|
|
db_session: AsyncSession,
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Process Wix form webhook payload.
|
|
|
|
Args:
|
|
payload: Parsed webhook payload
|
|
webhook_request: WebhookRequest database record
|
|
hotel: Hotel associated with this webhook
|
|
db_session: Database session
|
|
request: FastAPI Request object
|
|
|
|
Returns:
|
|
Response dict with status and details
|
|
"""
|
|
# Import here to avoid circular dependency
|
|
from .api import process_wix_form_submission
|
|
|
|
# Call existing processing function
|
|
result = await process_wix_form_submission(request, payload, db_session)
|
|
|
|
# The existing function doesn't return customer/reservation IDs directly,
|
|
# but they would be in the database session. We'll need to extract them
|
|
# from the result or query after the fact. For now, return the result as-is.
|
|
return result
|
|
|
|
|
|
async def process_generic_webhook_submission(
|
|
request: Request, data: dict[str, Any], db
|
|
):
|
|
"""Process generic webhook submissions with nested structure.
|
|
|
|
Expected structure:
|
|
{
|
|
"hotel_data": {"hotelname": "...", "hotelcode": "..."},
|
|
"form_data": {
|
|
"sprache": "de/it/en",
|
|
"anreise": "DD.MM.YYYY",
|
|
"abreise": "DD.MM.YYYY",
|
|
"erwachsene": "N",
|
|
"kinder": "N",
|
|
"alter": {"1": "age1", "2": "age2", ...},
|
|
"anrede": "...",
|
|
"name": "...",
|
|
"nachname": "...",
|
|
"mail": "...",
|
|
"tel": "...",
|
|
"nachricht": "..."
|
|
},
|
|
"tracking_data": {
|
|
"utm_source": "...",
|
|
"utm_medium": "...",
|
|
"utm_campaign": "...",
|
|
"utm_content": "...",
|
|
"utm_term": "...",
|
|
"fbclid": "...",
|
|
"gclid": "..."
|
|
},
|
|
"timestamp": "ISO8601"
|
|
}
|
|
"""
|
|
timestamp = datetime.now().isoformat()
|
|
_LOGGER.info("Processing generic webhook submission at %s", timestamp)
|
|
|
|
# Extract nested data
|
|
hotel_data = data.get("hotel_data", {})
|
|
form_data = data.get("form_data", {})
|
|
tracking_data = data.get("tracking_data", {})
|
|
offer_data = data.get("unterkunftTyp", {})
|
|
|
|
selected_offers = []
|
|
|
|
if offer_data:
|
|
# grab keys and values. If value is "on" add the key not the value to a list of selected offers
|
|
|
|
offer_data: dict[str, str]
|
|
|
|
for key, value in offer_data.items():
|
|
if value == "on":
|
|
selected_offers.append(key)
|
|
|
|
selected_offers_str = ", ".join(selected_offers) if selected_offers else None
|
|
|
|
# Extract hotel information
|
|
hotel_code = hotel_data.get("hotelcode")
|
|
hotel_name = hotel_data.get("hotelname")
|
|
|
|
if not hotel_code:
|
|
_LOGGER.warning("No hotel_code provided in webhook data, using default")
|
|
hotel_code = request.app.state.config.get("default_hotel_code", "123")
|
|
|
|
if not hotel_name:
|
|
hotel_name = (
|
|
request.app.state.config.get("default_hotel_name") or "Frangart Inn"
|
|
)
|
|
|
|
# Extract customer information
|
|
first_name = form_data.get("name")
|
|
last_name = form_data.get("nachname")
|
|
email = form_data.get("mail")
|
|
phone_number = form_data.get("tel")
|
|
name_prefix = form_data.get("anrede")
|
|
language = form_data.get("sprache", "de")[:2]
|
|
user_comment = form_data.get("nachricht", "")
|
|
plz = form_data.get("plz", "")
|
|
city = form_data.get("stadt", "")
|
|
country = form_data.get("land", "")
|
|
|
|
# Parse dates - handle DD.MM.YYYY format
|
|
start_date_str = form_data.get("anreise")
|
|
end_date_str = form_data.get("abreise")
|
|
|
|
if not start_date_str or not end_date_str:
|
|
raise HTTPException(
|
|
status_code=400, detail="Missing required dates (anreise/abreise)"
|
|
)
|
|
|
|
try:
|
|
# Parse DD.MM.YYYY format using strptime
|
|
start_date = datetime.strptime(start_date_str, "%d.%m.%Y").date()
|
|
end_date = datetime.strptime(end_date_str, "%d.%m.%Y").date()
|
|
except ValueError as e:
|
|
_LOGGER.error(
|
|
"Error parsing dates: start=%s, end=%s, error=%s",
|
|
start_date_str,
|
|
end_date_str,
|
|
e,
|
|
)
|
|
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}") from e
|
|
|
|
# Extract room/guest info
|
|
num_adults = int(form_data.get("erwachsene", 2))
|
|
num_children = int(form_data.get("kinder", 0))
|
|
|
|
# Extract children ages from nested structure
|
|
children_ages = []
|
|
if num_children > 0:
|
|
alter_data = form_data.get("alter", {})
|
|
for i in range(1, num_children + 1):
|
|
age_str = alter_data.get(str(i))
|
|
if age_str:
|
|
try:
|
|
children_ages.append(int(age_str))
|
|
except ValueError:
|
|
_LOGGER.warning("Invalid age value for child %d: %s", i, age_str)
|
|
|
|
# Extract tracking information
|
|
utm_source = None
|
|
utm_medium = None
|
|
utm_campaign = None
|
|
utm_term = None
|
|
utm_content = None
|
|
fbclid = None
|
|
gclid = None
|
|
|
|
if tracking_data:
|
|
utm_source = tracking_data.get("utm_source")
|
|
utm_medium = tracking_data.get("utm_medium")
|
|
utm_campaign = tracking_data.get("utm_campaign")
|
|
utm_term = tracking_data.get("utm_term")
|
|
utm_content = tracking_data.get("utm_content")
|
|
fbclid = tracking_data.get("fbclid")
|
|
gclid = tracking_data.get("gclid")
|
|
|
|
# Parse submission timestamp
|
|
submission_time = data.get("timestamp")
|
|
try:
|
|
if submission_time:
|
|
# Handle ISO8601 format with timezone
|
|
if submission_time.endswith("Z"):
|
|
submission_time = datetime.fromisoformat(submission_time[:-1])
|
|
elif "+" in submission_time:
|
|
# Remove timezone info (e.g., +02:00)
|
|
submission_time = datetime.fromisoformat(submission_time.split("+")[0])
|
|
else:
|
|
submission_time = datetime.fromisoformat(submission_time)
|
|
except Exception as e:
|
|
_LOGGER.exception("Error parsing submission timestamp: %s", e)
|
|
submission_time = None
|
|
|
|
# Generate unique ID
|
|
unique_id = generate_unique_id()
|
|
|
|
# Use CustomerService to handle customer creation/update with hashing
|
|
customer_service = CustomerService(db)
|
|
|
|
customer_data = {
|
|
"given_name": first_name,
|
|
"surname": last_name,
|
|
"contact_id": None,
|
|
"name_prefix": name_prefix if name_prefix != "--" else None,
|
|
"email_address": email,
|
|
"phone": phone_number if phone_number else None,
|
|
"email_newsletter": False,
|
|
"address_line": None,
|
|
"city_name": city if city else None,
|
|
"postal_code": plz if plz else None,
|
|
"country_code": country if country else None,
|
|
"gender": None,
|
|
"birth_date": None,
|
|
"language": language,
|
|
"address_catalog": False,
|
|
"name_title": None,
|
|
}
|
|
|
|
# Create/update customer
|
|
db_customer = await customer_service.get_or_create_customer(customer_data)
|
|
|
|
# Get advertising account IDs conditionally based on fbclid/gclid presence
|
|
meta_account_id, google_account_id = get_advertising_account_ids(
|
|
request.app.state.config, hotel_code, fbclid, gclid
|
|
)
|
|
|
|
# Create reservation
|
|
reservation_kwargs = {
|
|
"unique_id": unique_id,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"num_adults": num_adults,
|
|
"num_children": num_children,
|
|
"children_ages": children_ages,
|
|
"hotel_code": hotel_code,
|
|
"hotel_name": hotel_name,
|
|
"offer": selected_offers_str,
|
|
"utm_source": utm_source,
|
|
"utm_medium": utm_medium,
|
|
"utm_campaign": utm_campaign,
|
|
"utm_term": utm_term,
|
|
"utm_content": utm_content,
|
|
"user_comment": user_comment,
|
|
"fbclid": fbclid,
|
|
"gclid": gclid,
|
|
"meta_account_id": meta_account_id,
|
|
"google_account_id": google_account_id,
|
|
}
|
|
|
|
# Only include created_at if we have a valid submission_time
|
|
if submission_time:
|
|
reservation_kwargs["created_at"] = submission_time
|
|
|
|
reservation = ReservationData(**reservation_kwargs)
|
|
|
|
if reservation.md5_unique_id is None:
|
|
raise HTTPException(status_code=400, detail="Failed to generate md5_unique_id")
|
|
|
|
# Use ReservationService to create reservation
|
|
reservation_service = ReservationService(db)
|
|
db_reservation = await reservation_service.create_reservation(
|
|
reservation, db_customer.id
|
|
)
|
|
|
|
async def push_event():
|
|
# Fire event for listeners (push, etc.) - hotel-specific dispatch
|
|
dispatcher = getattr(request.app.state, "event_dispatcher", None)
|
|
if dispatcher:
|
|
# Get hotel_code from reservation to target the right listeners
|
|
hotel_code = getattr(db_reservation, "hotel_code", None)
|
|
if hotel_code and hotel_code.strip():
|
|
await dispatcher.dispatch_for_hotel(
|
|
"form_processed", hotel_code, db_customer, db_reservation
|
|
)
|
|
_LOGGER.info("Dispatched form_processed event for hotel %s", hotel_code)
|
|
else:
|
|
_LOGGER.warning(
|
|
"No hotel_code in reservation, skipping push notifications"
|
|
)
|
|
|
|
# Create task and store reference to prevent garbage collection
|
|
task = asyncio.create_task(push_event())
|
|
# Add done callback to log any exceptions
|
|
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
|
|
|
|
_LOGGER.info(
|
|
"Successfully processed generic webhook: customer_id=%s, reservation_id=%s",
|
|
db_customer.id,
|
|
db_reservation.id,
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Generic webhook data processed successfully",
|
|
"customer_id": db_customer.id,
|
|
"reservation_id": db_reservation.id,
|
|
"timestamp": timestamp,
|
|
}
|
|
|
|
|
|
class GenericWebhookProcessor:
|
|
"""Processor for generic webhooks."""
|
|
|
|
@property
|
|
def webhook_type(self) -> str:
|
|
"""Return webhook type identifier."""
|
|
return "generic"
|
|
|
|
async def process(
|
|
self,
|
|
payload: dict[str, Any],
|
|
webhook_request: WebhookRequest,
|
|
hotel: Hotel,
|
|
db_session: AsyncSession,
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
"""Process generic webhook payload.
|
|
|
|
Args:
|
|
payload: Parsed webhook payload
|
|
webhook_request: WebhookRequest database record
|
|
hotel: Hotel associated with this webhook
|
|
db_session: Database session
|
|
request: FastAPI Request object
|
|
|
|
Returns:
|
|
Response dict with status and details
|
|
"""
|
|
|
|
|
|
# Call existing processing function
|
|
result = await process_generic_webhook_submission(request, payload, db_session)
|
|
|
|
return result
|
|
|
|
|
|
# Global registry instance
|
|
webhook_registry = WebhookProcessorRegistry()
|
|
|
|
|
|
def initialize_webhook_processors() -> None:
|
|
"""Initialize and register all webhook processors.
|
|
|
|
This should be called during application startup.
|
|
"""
|
|
# Register built-in processors
|
|
webhook_registry.register(WixFormProcessor())
|
|
webhook_registry.register(GenericWebhookProcessor())
|
|
|
|
_LOGGER.info("Webhook processors initialized")
|