Files
customer-portal/customer_portal/services/wordpress_api.py

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