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.
This commit is contained in:
433
src/alpine_bits_python/webhook_processor.py
Normal file
433
src/alpine_bits_python/webhook_processor.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user