concurrency-fix #15
@@ -1,7 +1,8 @@
|
||||
"""Webhook processor interface and implementations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
@@ -81,6 +82,209 @@ class WebhookProcessorRegistry:
|
||||
return self._processors.get(webhook_type)
|
||||
|
||||
|
||||
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
|
||||
"""Shared business logic for handling Wix form submissions (test and production)."""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
_LOGGER.info("Received Wix form data at %s", timestamp)
|
||||
|
||||
data = data.get("data") # Handle nested "data" key if present
|
||||
|
||||
# save customer and reservation to DB
|
||||
|
||||
contact_info = data.get("contact", {})
|
||||
first_name = contact_info.get("name", {}).get("first")
|
||||
last_name = contact_info.get("name", {}).get("last")
|
||||
email = contact_info.get("email")
|
||||
phone_number = contact_info.get("phones", [{}])[0].get("e164Phone")
|
||||
contact_info.get("locale", "de-de")
|
||||
contact_id = contact_info.get("contactId")
|
||||
|
||||
name_prefix = data.get("field:anrede")
|
||||
|
||||
email_newsletter = data.get("field:form_field_5a7b", False)
|
||||
|
||||
# if email_newsletter is a string, attempt to convert to boolean, else false
|
||||
if isinstance(email_newsletter, str):
|
||||
email_newsletter = email_newsletter.lower() in [
|
||||
"yes",
|
||||
"true",
|
||||
"1",
|
||||
"on",
|
||||
"selezionato",
|
||||
"angekreuzt",
|
||||
]
|
||||
|
||||
address_line = None
|
||||
city_name = None
|
||||
postal_code = None
|
||||
country_code = None
|
||||
gender = None
|
||||
birth_date = None
|
||||
language = data.get("contact", {}).get("locale", "en")[:2]
|
||||
|
||||
# Dates
|
||||
start_date = (
|
||||
data.get("field:date_picker_a7c8")
|
||||
or data.get("Anreisedatum")
|
||||
or data.get("submissions", [{}])[1].get("value")
|
||||
)
|
||||
end_date = (
|
||||
data.get("field:date_picker_7e65")
|
||||
or data.get("Abreisedatum")
|
||||
or data.get("submissions", [{}])[2].get("value")
|
||||
)
|
||||
|
||||
# Room/guest info
|
||||
num_adults = int(data.get("field:number_7cf5") or 1)
|
||||
num_children = int(data.get("field:anzahl_kinder") or 0)
|
||||
children_ages = []
|
||||
if num_children > 0:
|
||||
# Collect all child age fields, then take only the first num_children
|
||||
# This handles form updates that may send extra padded/zero fields
|
||||
temp_ages = []
|
||||
for k in data:
|
||||
if k.startswith("field:alter_kind_"):
|
||||
if data[k] is None or data[k] == "":
|
||||
continue
|
||||
try:
|
||||
age = int(data[k])
|
||||
temp_ages.append(age)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid age value for %s: %s", k, data[k])
|
||||
|
||||
# Only keep the first num_children ages, regardless of their values
|
||||
children_ages = temp_ages[:num_children]
|
||||
|
||||
offer = data.get("field:angebot_auswaehlen")
|
||||
|
||||
# get submissionId and ensure max length 35. Generate one if not present
|
||||
|
||||
unique_id = data.get("submissionId", 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": contact_id,
|
||||
"name_prefix": name_prefix,
|
||||
"email_address": email,
|
||||
"phone": phone_number,
|
||||
"email_newsletter": email_newsletter,
|
||||
"address_line": address_line,
|
||||
"city_name": city_name,
|
||||
"postal_code": postal_code,
|
||||
"country_code": country_code,
|
||||
"gender": gender,
|
||||
"birth_date": birth_date,
|
||||
"language": language,
|
||||
"address_catalog": False,
|
||||
"name_title": None,
|
||||
}
|
||||
|
||||
# This automatically creates/updates both Customer and HashedCustomer
|
||||
db_customer = await customer_service.get_or_create_customer(customer_data)
|
||||
|
||||
# Determine hotel_code and hotel_name
|
||||
# Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback
|
||||
hotel_code = data.get("field:hotelid", None)
|
||||
|
||||
if hotel_code is None:
|
||||
_LOGGER.warning("No hotel_code provided in form data, using default")
|
||||
|
||||
hotel_code = request.app.state.config.get("default_hotel_code", "123")
|
||||
|
||||
hotel_name = (
|
||||
data.get("field:hotelname")
|
||||
or data.get("hotelname")
|
||||
or request.app.state.config.get("default_hotel_name")
|
||||
or "Frangart Inn" # fallback
|
||||
)
|
||||
|
||||
submissionTime = data.get("submissionTime") # 2025-10-07T05:48:41.855Z
|
||||
try:
|
||||
if submissionTime:
|
||||
submissionTime = datetime.fromisoformat(
|
||||
submissionTime[:-1]
|
||||
) # Remove Z and convert
|
||||
except Exception as e:
|
||||
_LOGGER.exception("Error parsing submissionTime: %s", e)
|
||||
submissionTime = None
|
||||
|
||||
# Extract fbclid and gclid for conditional account ID lookup
|
||||
fbclid = data.get("field:fbclid")
|
||||
gclid = data.get("field:gclid")
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
reservation = ReservationData(
|
||||
unique_id=unique_id,
|
||||
start_date=date.fromisoformat(start_date),
|
||||
end_date=date.fromisoformat(end_date),
|
||||
num_adults=num_adults,
|
||||
num_children=num_children,
|
||||
children_ages=children_ages,
|
||||
hotel_code=hotel_code,
|
||||
hotel_name=hotel_name,
|
||||
offer=offer,
|
||||
created_at=submissionTime,
|
||||
utm_source=data.get("field:utm_source"),
|
||||
utm_medium=data.get("field:utm_medium"),
|
||||
utm_campaign=data.get("field:utm_campaign"),
|
||||
utm_term=data.get("field:utm_term"),
|
||||
utm_content=data.get("field:utm_content"),
|
||||
user_comment=data.get("field:long_answer_3524", ""),
|
||||
fbclid=fbclid,
|
||||
gclid=gclid,
|
||||
meta_account_id=meta_account_id,
|
||||
google_account_id=google_account_id,
|
||||
)
|
||||
|
||||
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 it from being garbage collected
|
||||
# The task runs independently and we don't need to await it here
|
||||
task = asyncio.create_task(push_event())
|
||||
# Add done callback to log any exceptions that occur in the background task
|
||||
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Wix form data received successfully",
|
||||
"received_keys": list(data.keys()),
|
||||
"timestamp": timestamp,
|
||||
"note": "No authentication required for this endpoint",
|
||||
}
|
||||
|
||||
|
||||
class WixFormProcessor:
|
||||
"""Processor for Wix form webhooks."""
|
||||
|
||||
@@ -110,7 +314,6 @@ class WixFormProcessor:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user