concurrency-fix #15
@@ -1,7 +1,8 @@
|
|||||||
"""Webhook processor interface and implementations."""
|
"""Webhook processor interface and implementations."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
import asyncio
|
||||||
|
from datetime import date, datetime
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
@@ -81,6 +82,209 @@ class WebhookProcessorRegistry:
|
|||||||
return self._processors.get(webhook_type)
|
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:
|
class WixFormProcessor:
|
||||||
"""Processor for Wix form webhooks."""
|
"""Processor for Wix form webhooks."""
|
||||||
|
|
||||||
@@ -110,7 +314,6 @@ class WixFormProcessor:
|
|||||||
Response dict with status and details
|
Response dict with status and details
|
||||||
"""
|
"""
|
||||||
# Import here to avoid circular dependency
|
# Import here to avoid circular dependency
|
||||||
from .api import process_wix_form_submission
|
|
||||||
|
|
||||||
# Call existing processing function
|
# Call existing processing function
|
||||||
result = await process_wix_form_submission(request, payload, db_session)
|
result = await process_wix_form_submission(request, payload, db_session)
|
||||||
|
|||||||
Reference in New Issue
Block a user