556 lines
19 KiB
Python
Executable File
556 lines
19 KiB
Python
Executable File
"""WordPress API client and webhook handler."""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from flask import current_app
|
|
|
|
from customer_portal.models.customer import Customer
|
|
from customer_portal.services.email import EmailService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WordPressWebhook:
|
|
"""Handle webhooks from WordPress."""
|
|
|
|
@staticmethod
|
|
def handle_booking_created(db_session, data: dict) -> dict:
|
|
"""Auto-register customer on booking.
|
|
|
|
Called when a booking is created in WordPress.
|
|
Creates customer if not exists, sends welcome email.
|
|
|
|
Args:
|
|
db_session: Database session
|
|
data: Webhook payload with email, name, phone
|
|
|
|
Returns:
|
|
Dict with customer_id or error
|
|
"""
|
|
email = data.get("email", "").strip().lower()
|
|
name = data.get("name", "").strip()
|
|
phone = data.get("phone", "").strip() if data.get("phone") else None
|
|
|
|
if not email or "@" not in email:
|
|
logger.warning("Webhook received without valid email")
|
|
return {"error": "Email required"}
|
|
|
|
# Check if customer exists
|
|
customer = db_session.query(Customer).filter(Customer.email == email).first()
|
|
|
|
if customer:
|
|
logger.info(f"Customer already exists: {email}")
|
|
return {
|
|
"customer_id": customer.id,
|
|
"created": False,
|
|
"message": "Customer already exists",
|
|
}
|
|
|
|
# Create new customer with defaults from settings
|
|
customer = Customer.create_with_defaults(
|
|
db_session,
|
|
email=email,
|
|
name=name if name else email.split("@")[0],
|
|
phone=phone,
|
|
)
|
|
db_session.add(customer)
|
|
db_session.commit()
|
|
|
|
logger.info(f"New customer created via webhook: {email}")
|
|
|
|
# Send welcome email (Sprint 12: use display_name)
|
|
try:
|
|
EmailService.send_welcome(email, customer.display_name)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send welcome email to {email}: {e}")
|
|
|
|
return {
|
|
"customer_id": customer.id,
|
|
"created": True,
|
|
"message": "Customer created successfully",
|
|
}
|
|
|
|
|
|
class WordPressAPI:
|
|
"""Client for WordPress REST API."""
|
|
|
|
@staticmethod
|
|
def _get_client() -> httpx.Client:
|
|
"""Get configured HTTP client.
|
|
|
|
Returns:
|
|
httpx.Client configured with base URL and auth headers.
|
|
"""
|
|
base_url = current_app.config.get("WP_API_URL", "")
|
|
secret = current_app.config.get("WP_API_SECRET", "")
|
|
|
|
if not base_url:
|
|
raise ValueError("WP_API_URL not configured")
|
|
|
|
return httpx.Client(
|
|
base_url=base_url,
|
|
headers={
|
|
"X-Portal-Secret": secret,
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
|
|
@staticmethod
|
|
def get_bookings(email: str) -> list[dict[str, Any]]:
|
|
"""Get bookings for customer.
|
|
|
|
Args:
|
|
email: Customer email address
|
|
|
|
Returns:
|
|
List of booking dictionaries
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get("/bookings", params={"email": email})
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"HTTP error fetching bookings: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching bookings for {email}: {e}")
|
|
return []
|
|
|
|
@staticmethod
|
|
def get_booking(booking_id: int, email: str | None = None) -> dict[str, Any] | None:
|
|
"""Get single booking details.
|
|
|
|
Args:
|
|
booking_id: Booking ID
|
|
email: Customer email for verification
|
|
|
|
Returns:
|
|
Booking dictionary or None if not found
|
|
"""
|
|
try:
|
|
params = {"email": email} if email else {}
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get(f"/bookings/{booking_id}", params=params)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
return None
|
|
if e.response.status_code == 403:
|
|
logger.warning(f"Access denied to booking {booking_id}")
|
|
return None
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching booking {booking_id}: {e}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def cancel_booking(booking_id: int, email: str, reason: str = "") -> dict[str, Any]:
|
|
"""Request booking cancellation.
|
|
|
|
Args:
|
|
booking_id: Booking ID
|
|
email: Customer email for verification
|
|
reason: Cancellation reason
|
|
|
|
Returns:
|
|
Response dictionary with success status
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.post(
|
|
f"/bookings/{booking_id}/cancel",
|
|
json={"email": email, "reason": reason},
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
error_data = {"success": False, "error": str(e)}
|
|
try:
|
|
error_data = e.response.json()
|
|
except Exception:
|
|
pass
|
|
return error_data
|
|
except Exception as e:
|
|
logger.error(f"Error cancelling booking {booking_id}: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
@staticmethod
|
|
def get_invoices(email: str) -> list[dict[str, Any]]:
|
|
"""Get invoices for customer.
|
|
|
|
Args:
|
|
email: Customer email address
|
|
|
|
Returns:
|
|
List of invoice dictionaries
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get("/invoices", params={"email": email})
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 503:
|
|
logger.info("sevDesk not configured in WordPress")
|
|
return []
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching invoices for {email}: {e}")
|
|
return []
|
|
|
|
@staticmethod
|
|
def get_invoice_pdf(invoice_id: int, email: str) -> dict[str, Any] | None:
|
|
"""Get invoice PDF from WordPress/sevDesk.
|
|
|
|
Args:
|
|
invoice_id: sevDesk invoice ID
|
|
email: Customer email for ownership verification
|
|
|
|
Returns:
|
|
Dict with pdf (base64), filename, encoding or None on error
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get(
|
|
f"/invoices/{invoice_id}/pdf", params={"email": email}
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 403:
|
|
logger.warning(f"Access denied to invoice PDF {invoice_id}")
|
|
return None
|
|
if e.response.status_code == 503:
|
|
logger.info("sevDesk not configured in WordPress")
|
|
return None
|
|
logger.error(f"HTTP error fetching invoice PDF: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error fetching invoice PDF {invoice_id}: {e}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_videos(email: str) -> list[dict[str, Any]]:
|
|
"""Get videos available to customer.
|
|
|
|
Args:
|
|
email: Customer email address
|
|
|
|
Returns:
|
|
List of video dictionaries
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get("/videos", params={"email": email})
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 503:
|
|
logger.info("Video module not active in WordPress")
|
|
return []
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching videos for {email}: {e}")
|
|
return []
|
|
|
|
@staticmethod
|
|
def get_video_stream(video_id: int, email: str) -> dict[str, Any] | None:
|
|
"""Get video stream URL from WordPress.
|
|
|
|
WordPress handles the Video-Service token generation internally.
|
|
|
|
Args:
|
|
video_id: WordPress video post ID
|
|
email: Customer email for access verification
|
|
|
|
Returns:
|
|
Dict with stream_url or error, None on failure
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get(
|
|
f"/videos/{video_id}/stream",
|
|
params={"email": email},
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 403:
|
|
logger.warning(f"Access denied to video stream {video_id}")
|
|
return {"error": "Kein Zugriff auf dieses Video"}
|
|
if e.response.status_code == 404:
|
|
return {"error": "Video nicht gefunden"}
|
|
if e.response.status_code == 503:
|
|
logger.info("Video service not available")
|
|
return {"error": "Video-Service nicht verfuegbar"}
|
|
logger.error(f"HTTP error fetching video stream: {e}")
|
|
return {"error": str(e)}
|
|
except Exception as e:
|
|
logger.error(f"Error fetching video stream {video_id}: {e}")
|
|
return None
|
|
|
|
# Sprint 6.6: Schema and field synchronization methods
|
|
|
|
@staticmethod
|
|
def get_schema(kurs_id: int | None = None) -> dict[str, Any]:
|
|
"""Get booking form field schema from WordPress.
|
|
|
|
Retrieves all field definitions (core + custom) from WordPress.
|
|
This enables dynamic form generation in the portal.
|
|
|
|
Args:
|
|
kurs_id: Optional kurs ID for kurs-specific fields
|
|
|
|
Returns:
|
|
Schema dictionary with core_fields, custom_fields, editable
|
|
"""
|
|
try:
|
|
params = {}
|
|
if kurs_id:
|
|
params["kurs_id"] = kurs_id
|
|
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.get("/schema", params=params)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"HTTP error fetching schema: {e}")
|
|
return {"core_fields": [], "custom_fields": [], "editable": {}}
|
|
except Exception as e:
|
|
logger.error(f"Error fetching schema: {e}")
|
|
return {"core_fields": [], "custom_fields": [], "editable": {}}
|
|
|
|
@staticmethod
|
|
def update_booking(
|
|
booking_id: int, email: str, fields: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Update booking fields via PATCH request.
|
|
|
|
Only editable fields (as defined by WordPress) can be updated.
|
|
|
|
Args:
|
|
booking_id: Booking ID to update
|
|
email: Customer email for ownership verification
|
|
fields: Dictionary of field names and values to update
|
|
|
|
Returns:
|
|
Response dictionary with success status and updated fields
|
|
"""
|
|
try:
|
|
with WordPressAPI._get_client() as client:
|
|
response = client.patch(
|
|
f"/bookings/{booking_id}",
|
|
json={"email": email, "fields": fields},
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.HTTPStatusError as e:
|
|
error_data = {"success": False, "error": str(e)}
|
|
try:
|
|
error_data = e.response.json()
|
|
except Exception:
|
|
pass
|
|
return error_data
|
|
except Exception as e:
|
|
logger.error(f"Error updating booking {booking_id}: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
# Consent fields that should NOT be overwritten for existing customers
|
|
CONSENT_FIELDS = {
|
|
"mec_field_11", # AGB akzeptiert
|
|
"mec_field_12", # Buchungsbestaetigung
|
|
"mec_field_14", # Kurserinnerung
|
|
"mec_field_15", # Rechnung per E-Mail
|
|
"mec_field_16", # Marketing
|
|
"mec_field_17", # Newsletter
|
|
}
|
|
|
|
@staticmethod
|
|
def sync_customer_fields_from_booking(customer, booking_id: int) -> dict[str, Any]:
|
|
"""Sync custom fields from a booking to customer profile.
|
|
|
|
Fetches booking data and updates customer's custom_fields
|
|
with values from the booking.
|
|
|
|
IMPORTANT: Consent fields (AGB, Marketing, Newsletter, etc.) are
|
|
NEVER overwritten for existing customers. The portal is the
|
|
master source for these preferences.
|
|
|
|
Args:
|
|
customer: Customer model instance
|
|
booking_id: Booking ID to sync from
|
|
|
|
Returns:
|
|
Dictionary with synced fields
|
|
"""
|
|
booking = WordPressAPI.get_booking(booking_id, customer.email)
|
|
if not booking:
|
|
return {}
|
|
|
|
synced = {}
|
|
|
|
# Sync custom_fields from booking
|
|
custom_fields = booking.get("custom_fields", {})
|
|
if custom_fields:
|
|
existing = customer.get_custom_fields()
|
|
|
|
# Check if customer already has consent fields (= existing customer)
|
|
has_existing_consent = any(
|
|
key in existing for key in WordPressAPI.CONSENT_FIELDS
|
|
)
|
|
|
|
for key, value in custom_fields.items():
|
|
# Never overwrite consent fields for existing customers
|
|
if has_existing_consent and key in WordPressAPI.CONSENT_FIELDS:
|
|
logger.debug(f"Skipping consent field {key} for existing customer")
|
|
continue
|
|
existing[key] = value
|
|
synced[key] = value
|
|
|
|
customer.set_custom_fields(existing)
|
|
|
|
# Sprint 12: Sync phone into custom_fields (not fixed column)
|
|
phone = booking.get("customer", {}).get("phone")
|
|
if phone and not existing.get("phone"):
|
|
existing["phone"] = phone
|
|
synced["phone"] = phone
|
|
customer.set_custom_fields(existing)
|
|
|
|
return synced
|
|
|
|
@staticmethod
|
|
def sync_bookings_for_customer(db_session, customer) -> dict[str, Any]:
|
|
"""Synchronize all bookings for a customer from WordPress.
|
|
|
|
Fetches all bookings from WordPress API and creates/updates
|
|
them in the local database.
|
|
|
|
Args:
|
|
db_session: Database session
|
|
customer: Customer model instance
|
|
|
|
Returns:
|
|
Dictionary with sync results (created, updated, errors)
|
|
"""
|
|
from customer_portal.models.booking import Booking
|
|
|
|
result = {
|
|
"created": 0,
|
|
"updated": 0,
|
|
"errors": [],
|
|
"bookings": [],
|
|
}
|
|
|
|
try:
|
|
# Get basic booking list
|
|
bookings_list = WordPressAPI.get_bookings(customer.email)
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch bookings for {customer.email}: {e}")
|
|
result["errors"].append(f"API error: {str(e)}")
|
|
return result
|
|
|
|
for booking_summary in bookings_list:
|
|
wp_booking_id = booking_summary.get("id")
|
|
if not wp_booking_id:
|
|
continue
|
|
|
|
try:
|
|
# Get full booking details
|
|
booking_detail = WordPressAPI.get_booking(wp_booking_id, customer.email)
|
|
if not booking_detail:
|
|
result["errors"].append(
|
|
f"Could not fetch details for booking {wp_booking_id}"
|
|
)
|
|
continue
|
|
|
|
# Create or update in local DB
|
|
booking, is_new = Booking.create_or_update_from_wp(
|
|
db_session, customer.id, booking_detail
|
|
)
|
|
|
|
if is_new:
|
|
result["created"] += 1
|
|
else:
|
|
result["updated"] += 1
|
|
|
|
result["bookings"].append(
|
|
{
|
|
"id": booking.id,
|
|
"wp_id": wp_booking_id,
|
|
"number": booking.booking_number,
|
|
"is_new": is_new,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing booking {wp_booking_id}: {e}")
|
|
result["errors"].append(f"Booking {wp_booking_id}: {str(e)}")
|
|
|
|
# Commit all changes
|
|
try:
|
|
db_session.commit()
|
|
except Exception as e:
|
|
db_session.rollback()
|
|
logger.error(f"Database commit failed: {e}")
|
|
result["errors"].append(f"Database error: {str(e)}")
|
|
|
|
logger.info(
|
|
f"Synced bookings for {customer.email}: "
|
|
f"{result['created']} created, {result['updated']} updated"
|
|
)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def sync_all_customer_bookings(db_session) -> dict[str, Any]:
|
|
"""Synchronize bookings for all customers.
|
|
|
|
Args:
|
|
db_session: Database session
|
|
|
|
Returns:
|
|
Dictionary with overall sync results
|
|
"""
|
|
from customer_portal.models.customer import Customer
|
|
|
|
customers = db_session.query(Customer).all()
|
|
total_result = {
|
|
"customers_synced": 0,
|
|
"total_created": 0,
|
|
"total_updated": 0,
|
|
"errors": [],
|
|
"per_customer": [],
|
|
}
|
|
|
|
for customer in customers:
|
|
result = WordPressAPI.sync_bookings_for_customer(db_session, customer)
|
|
|
|
total_result["customers_synced"] += 1
|
|
total_result["total_created"] += result["created"]
|
|
total_result["total_updated"] += result["updated"]
|
|
|
|
if result["errors"]:
|
|
total_result["errors"].extend(result["errors"])
|
|
|
|
total_result["per_customer"].append(
|
|
{
|
|
"email": customer.email,
|
|
"name": customer.display_name,
|
|
"created": result["created"],
|
|
"updated": result["updated"],
|
|
"errors": len(result["errors"]),
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
f"Full sync complete: {total_result['customers_synced']} customers, "
|
|
f"{total_result['total_created']} created, {total_result['total_updated']} updated"
|
|
)
|
|
|
|
return total_result
|