Files
alpinebits_python/src/alpine_bits_python/webhook_processor.py
Jonas Linter 8d144a761c feat: Add hotel and webhook endpoint management
- 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.
2025-11-25 12:05:48 +01:00

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")