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