Initial commit - Customer Portal for Coolify
This commit is contained in:
7
customer_portal/services/__init__.py
Executable file
7
customer_portal/services/__init__.py
Executable file
@@ -0,0 +1,7 @@
|
||||
"""Business logic services."""
|
||||
|
||||
from customer_portal.services.auth import AuthService
|
||||
from customer_portal.services.email import EmailService
|
||||
from customer_portal.services.otp import OTPService
|
||||
|
||||
__all__ = ["AuthService", "EmailService", "OTPService"]
|
||||
BIN
customer_portal/services/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/auth.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/auth.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/auth.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/auth.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/booking_import.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/booking_import.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/email.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/email.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/email.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/email.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/otp.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/otp.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/token.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/token.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/token.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/token.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-312.pyc
Executable file
Binary file not shown.
128
customer_portal/services/auth.py
Executable file
128
customer_portal/services/auth.py
Executable file
@@ -0,0 +1,128 @@
|
||||
"""Authentication service."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from customer_portal.models.customer import Customer
|
||||
from customer_portal.models.session import Session
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handle authentication."""
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_customer(db_session, email: str) -> Customer:
|
||||
"""Get existing customer or create new one.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
email: Customer email
|
||||
|
||||
Returns:
|
||||
Customer instance
|
||||
"""
|
||||
customer = db_session.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
if not customer:
|
||||
customer = Customer.create_with_defaults(
|
||||
db_session,
|
||||
email=email,
|
||||
name=email.split("@")[0], # Temporary name from email
|
||||
)
|
||||
db_session.add(customer)
|
||||
db_session.commit()
|
||||
|
||||
return customer
|
||||
|
||||
@staticmethod
|
||||
def create_session(
|
||||
db_session, customer_id: int, ip_address: str, user_agent: str
|
||||
) -> str:
|
||||
"""Create login session.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
ip_address: Client IP address
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
Session token
|
||||
"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
hours = current_app.config.get("SESSION_LIFETIME_HOURS", 24)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
session = Session(
|
||||
customer_id=customer_id,
|
||||
token=token,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db_session.add(session)
|
||||
|
||||
# Update last login
|
||||
customer = db_session.query(Customer).get(customer_id)
|
||||
if customer:
|
||||
customer.last_login_at = datetime.now(UTC)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def get_customer_by_token(db_session, token: str) -> Customer | None:
|
||||
"""Get customer from session token.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
token: Session token
|
||||
|
||||
Returns:
|
||||
Customer if valid session, None otherwise
|
||||
"""
|
||||
session = (
|
||||
db_session.query(Session)
|
||||
.filter(
|
||||
Session.token == token,
|
||||
Session.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if session:
|
||||
return db_session.query(Customer).get(session.customer_id)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def logout(db_session, token: str) -> None:
|
||||
"""Delete session.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
token: Session token
|
||||
"""
|
||||
db_session.query(Session).filter(Session.token == token).delete()
|
||||
db_session.commit()
|
||||
|
||||
@staticmethod
|
||||
def cleanup_expired_sessions(db_session) -> int:
|
||||
"""Remove expired sessions.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
|
||||
Returns:
|
||||
Number of deleted sessions
|
||||
"""
|
||||
result = (
|
||||
db_session.query(Session)
|
||||
.filter(Session.expires_at < datetime.now(UTC))
|
||||
.delete()
|
||||
)
|
||||
db_session.commit()
|
||||
return result
|
||||
752
customer_portal/services/booking_import.py
Executable file
752
customer_portal/services/booking_import.py
Executable file
@@ -0,0 +1,752 @@
|
||||
"""Booking Import Service.
|
||||
|
||||
Supports import from CSV, JSON (MEC format), and Excel files.
|
||||
Features:
|
||||
- Automatic customer matching/creation by email
|
||||
- Overwrite protection (skip existing bookings by default)
|
||||
- Validation and error reporting
|
||||
- Support for MEC WordPress export format
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from customer_portal.models.booking import Booking
|
||||
from customer_portal.models.customer import Customer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookingImportService:
|
||||
"""Service for importing bookings from various file formats."""
|
||||
|
||||
# Field mappings for different formats
|
||||
FIELD_MAPPINGS = {
|
||||
# CSV/Excel column -> internal field
|
||||
"id": "wp_booking_id",
|
||||
"wp_id": "wp_booking_id",
|
||||
"wordpress_id": "wp_booking_id",
|
||||
"buchungsnummer": "booking_number",
|
||||
"booking_number": "booking_number",
|
||||
"number": "booking_number",
|
||||
"kurs_id": "wp_kurs_id",
|
||||
"kurs": "kurs_title",
|
||||
"kurs_title": "kurs_title",
|
||||
"kurs_titel": "kurs_title",
|
||||
"course": "kurs_title",
|
||||
"course_title": "kurs_title",
|
||||
"datum": "kurs_date",
|
||||
"date": "kurs_date",
|
||||
"kurs_date": "kurs_date",
|
||||
"kurs_datum": "kurs_date",
|
||||
"uhrzeit": "kurs_time",
|
||||
"time": "kurs_time",
|
||||
"kurs_time": "kurs_time",
|
||||
"end_time": "kurs_end_time",
|
||||
"kurs_end_time": "kurs_end_time",
|
||||
"ort": "kurs_location",
|
||||
"location": "kurs_location",
|
||||
"kurs_location": "kurs_location",
|
||||
"status": "status",
|
||||
"buchungsstatus": "status",
|
||||
"preis": "total_price",
|
||||
"price": "total_price",
|
||||
"total_price": "total_price",
|
||||
"gesamtpreis": "total_price",
|
||||
"ticket_typ": "ticket_type",
|
||||
"ticket_type": "ticket_type",
|
||||
"anzahl": "ticket_count",
|
||||
"count": "ticket_count",
|
||||
"ticket_count": "ticket_count",
|
||||
"name": "customer_name",
|
||||
"kunde": "customer_name",
|
||||
"customer_name": "customer_name",
|
||||
"kundenname": "customer_name",
|
||||
"email": "customer_email",
|
||||
"e-mail": "customer_email",
|
||||
"customer_email": "customer_email",
|
||||
"telefon": "customer_phone",
|
||||
"phone": "customer_phone",
|
||||
"customer_phone": "customer_phone",
|
||||
"sevdesk_invoice_id": "sevdesk_invoice_id",
|
||||
"rechnung_id": "sevdesk_invoice_id",
|
||||
"sevdesk_invoice_number": "sevdesk_invoice_number",
|
||||
"rechnungsnummer": "sevdesk_invoice_number",
|
||||
"erstellt": "wp_created_at",
|
||||
"created_at": "wp_created_at",
|
||||
"wp_created_at": "wp_created_at",
|
||||
}
|
||||
|
||||
# Status mappings (German -> internal)
|
||||
STATUS_MAPPINGS = {
|
||||
"bestaetigt": "confirmed",
|
||||
"bestätigt": "confirmed",
|
||||
"confirmed": "confirmed",
|
||||
"ausstehend": "pending",
|
||||
"pending": "pending",
|
||||
"storniert": "cancelled",
|
||||
"cancelled": "cancelled",
|
||||
"canceled": "cancelled",
|
||||
"stornierung angefragt": "cancel_requested",
|
||||
"cancel_requested": "cancel_requested",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def import_from_csv(
|
||||
cls,
|
||||
db_session,
|
||||
file_content: str | bytes,
|
||||
overwrite: bool = False,
|
||||
delimiter: str = ";",
|
||||
) -> dict[str, Any]:
|
||||
"""Import bookings from CSV file.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
file_content: CSV file content (string or bytes)
|
||||
overwrite: If True, update existing bookings. If False, skip them.
|
||||
delimiter: CSV delimiter (default: semicolon for German Excel)
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
if isinstance(file_content, bytes):
|
||||
# Try UTF-8 with BOM first, then UTF-8, then Latin-1
|
||||
for encoding in ["utf-8-sig", "utf-8", "latin-1"]:
|
||||
try:
|
||||
file_content = file_content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
reader = csv.DictReader(io.StringIO(file_content), delimiter=delimiter)
|
||||
rows = list(reader)
|
||||
|
||||
return cls._import_rows(db_session, rows, overwrite, source="CSV")
|
||||
|
||||
@classmethod
|
||||
def import_from_json(
|
||||
cls,
|
||||
db_session,
|
||||
file_content: str | bytes,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import bookings from JSON file (MEC format).
|
||||
|
||||
Supports both:
|
||||
- Array of booking objects: [{"id": 1, ...}, ...]
|
||||
- MEC export format: {"bookings": [...], "meta": {...}}
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
file_content: JSON file content
|
||||
overwrite: If True, update existing bookings
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
if isinstance(file_content, bytes):
|
||||
file_content = file_content.decode("utf-8-sig")
|
||||
|
||||
data = json.loads(file_content)
|
||||
|
||||
# Handle different JSON structures
|
||||
if isinstance(data, list):
|
||||
rows = data
|
||||
elif isinstance(data, dict):
|
||||
# MEC export format or similar
|
||||
rows = data.get("bookings", data.get("data", data.get("items", [])))
|
||||
if not rows and "id" in data:
|
||||
# Single booking object
|
||||
rows = [data]
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Ungültiges JSON-Format",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
return cls._import_rows(db_session, rows, overwrite, source="JSON")
|
||||
|
||||
@classmethod
|
||||
def import_from_excel(
|
||||
cls,
|
||||
db_session,
|
||||
file_content: bytes,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import bookings from Excel file (.xlsx).
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
file_content: Excel file content (bytes)
|
||||
overwrite: If True, update existing bookings
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "openpyxl nicht installiert. Bitte 'pip install openpyxl' ausführen.",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
workbook = openpyxl.load_workbook(io.BytesIO(file_content), data_only=True)
|
||||
sheet = workbook.active
|
||||
|
||||
# Get headers from first row
|
||||
headers = [cell.value for cell in sheet[1] if cell.value]
|
||||
if not headers:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Keine Spaltenüberschriften gefunden",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# Convert rows to dictionaries
|
||||
rows = []
|
||||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
||||
if any(cell is not None for cell in row):
|
||||
row_dict = {}
|
||||
for i, value in enumerate(row):
|
||||
if i < len(headers) and headers[i]:
|
||||
row_dict[headers[i]] = value
|
||||
rows.append(row_dict)
|
||||
|
||||
return cls._import_rows(db_session, rows, overwrite, source="Excel")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Excel import error")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Excel-Lesefehler: {e!s}",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _import_rows(
|
||||
cls,
|
||||
db_session,
|
||||
rows: list[dict],
|
||||
overwrite: bool,
|
||||
source: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Process and import rows.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
rows: List of row dictionaries
|
||||
overwrite: If True, update existing bookings
|
||||
source: Source format name for logging
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
result = {
|
||||
"success": True,
|
||||
"source": source,
|
||||
"total_rows": len(rows),
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"skipped_existing": 0,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
if not rows:
|
||||
result["warnings"].append("Keine Daten zum Importieren gefunden")
|
||||
return result
|
||||
|
||||
for row_num, row in enumerate(rows, start=2): # Start at 2 (header is row 1)
|
||||
try:
|
||||
import_result = cls._import_single_booking(
|
||||
db_session, row, overwrite, row_num
|
||||
)
|
||||
|
||||
if import_result["status"] == "created":
|
||||
result["created"] += 1
|
||||
elif import_result["status"] == "updated":
|
||||
result["updated"] += 1
|
||||
elif import_result["status"] == "skipped_existing":
|
||||
result["skipped_existing"] += 1
|
||||
result["skipped"] += 1
|
||||
elif import_result["status"] == "skipped":
|
||||
result["skipped"] += 1
|
||||
if import_result.get("reason"):
|
||||
result["warnings"].append(
|
||||
f"Zeile {row_num}: {import_result['reason']}"
|
||||
)
|
||||
|
||||
if import_result.get("error"):
|
||||
result["errors"].append(
|
||||
f"Zeile {row_num}: {import_result['error']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error importing row {row_num}")
|
||||
result["errors"].append(f"Zeile {row_num}: {e!s}")
|
||||
|
||||
# Commit if we have any successful imports
|
||||
if result["created"] > 0 or result["updated"] > 0:
|
||||
try:
|
||||
db_session.commit()
|
||||
except Exception as e:
|
||||
db_session.rollback()
|
||||
result["success"] = False
|
||||
result["error"] = f"Datenbank-Fehler: {e!s}"
|
||||
result["created"] = 0
|
||||
result["updated"] = 0
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _import_single_booking(
|
||||
cls,
|
||||
db_session,
|
||||
row: dict,
|
||||
overwrite: bool,
|
||||
row_num: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Import a single booking row.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
row: Row dictionary
|
||||
overwrite: If True, update existing bookings
|
||||
row_num: Row number for error reporting
|
||||
|
||||
Returns:
|
||||
Single import result
|
||||
"""
|
||||
# Normalize field names
|
||||
normalized = cls._normalize_row(row)
|
||||
|
||||
# Validate required fields
|
||||
if not normalized.get("customer_email"):
|
||||
return {"status": "skipped", "reason": "Keine E-Mail-Adresse"}
|
||||
|
||||
# Find or create customer
|
||||
customer = cls._find_or_create_customer(db_session, normalized)
|
||||
|
||||
# Check for existing booking
|
||||
wp_booking_id = normalized.get("wp_booking_id")
|
||||
booking_number = normalized.get("booking_number")
|
||||
|
||||
existing = None
|
||||
if wp_booking_id:
|
||||
existing = (
|
||||
db_session.query(Booking)
|
||||
.filter(Booking.wp_booking_id == int(wp_booking_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing and booking_number:
|
||||
existing = (
|
||||
db_session.query(Booking)
|
||||
.filter(Booking.booking_number == str(booking_number))
|
||||
.first()
|
||||
)
|
||||
|
||||
# Overwrite protection
|
||||
if existing and not overwrite:
|
||||
return {
|
||||
"status": "skipped_existing",
|
||||
"reason": f"Buchung existiert bereits (ID: {existing.id})",
|
||||
}
|
||||
|
||||
# Create or update booking
|
||||
if existing:
|
||||
booking = existing
|
||||
status = "updated"
|
||||
else:
|
||||
# Generate wp_booking_id if not provided
|
||||
if not wp_booking_id:
|
||||
# Use negative IDs for imported bookings without WP ID
|
||||
max_negative = (
|
||||
db_session.query(Booking.wp_booking_id)
|
||||
.filter(Booking.wp_booking_id < 0)
|
||||
.order_by(Booking.wp_booking_id.asc())
|
||||
.first()
|
||||
)
|
||||
wp_booking_id = (max_negative[0] - 1) if max_negative else -1
|
||||
|
||||
booking = Booking(
|
||||
customer_id=customer.id,
|
||||
wp_booking_id=int(wp_booking_id),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db_session.add(booking)
|
||||
status = "created"
|
||||
|
||||
# Update booking fields
|
||||
cls._update_booking_fields(booking, normalized)
|
||||
booking.customer_id = customer.id
|
||||
booking.synced_at = datetime.now(UTC)
|
||||
booking.updated_at = datetime.now(UTC)
|
||||
|
||||
return {"status": status, "booking_id": booking.id if existing else None}
|
||||
|
||||
@classmethod
|
||||
def _normalize_row(cls, row: dict) -> dict:
|
||||
"""Normalize row field names to internal format.
|
||||
|
||||
Args:
|
||||
row: Raw row dictionary
|
||||
|
||||
Returns:
|
||||
Normalized dictionary
|
||||
"""
|
||||
normalized = {}
|
||||
custom_fields = {}
|
||||
|
||||
# Handle nested 'customer' object (MEC format)
|
||||
if "customer" in row and isinstance(row["customer"], dict):
|
||||
customer_data = row["customer"]
|
||||
if customer_data.get("email"):
|
||||
normalized["customer_email"] = customer_data["email"]
|
||||
if customer_data.get("name"):
|
||||
normalized["customer_name"] = customer_data["name"]
|
||||
if customer_data.get("phone"):
|
||||
normalized["customer_phone"] = customer_data["phone"]
|
||||
|
||||
for key, value in row.items():
|
||||
if value is None or value == "":
|
||||
continue
|
||||
|
||||
# Skip nested objects (already processed above)
|
||||
if isinstance(value, dict):
|
||||
continue
|
||||
|
||||
# Convert key to lowercase and strip
|
||||
key_lower = str(key).lower().strip()
|
||||
|
||||
# Check if it's a known field
|
||||
if key_lower in cls.FIELD_MAPPINGS:
|
||||
internal_key = cls.FIELD_MAPPINGS[key_lower]
|
||||
normalized[internal_key] = value
|
||||
else:
|
||||
# Store as custom field
|
||||
custom_fields[key] = value
|
||||
|
||||
if custom_fields:
|
||||
normalized["custom_fields"] = custom_fields
|
||||
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _find_or_create_customer(cls, db_session, data: dict) -> Customer:
|
||||
"""Find existing customer or create new one.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
data: Normalized booking data
|
||||
|
||||
Returns:
|
||||
Customer instance
|
||||
"""
|
||||
email = data.get("customer_email", "").lower().strip()
|
||||
|
||||
customer = db_session.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
if not customer:
|
||||
# Sprint 12: All customer data goes to custom_fields
|
||||
full_name = data.get("customer_name", "")
|
||||
phone = data.get("customer_phone", "")
|
||||
|
||||
# Store name and phone in custom_fields
|
||||
custom_fields = {}
|
||||
if full_name:
|
||||
custom_fields["name"] = full_name
|
||||
if phone:
|
||||
custom_fields["phone"] = phone
|
||||
|
||||
# Create customer with only required fields
|
||||
customer = Customer(
|
||||
email=email,
|
||||
custom_fields=custom_fields if custom_fields else None,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
db_session.add(customer)
|
||||
db_session.flush() # Get ID
|
||||
|
||||
return customer
|
||||
|
||||
@classmethod
|
||||
def _update_booking_fields(cls, booking: Booking, data: dict) -> None:
|
||||
"""Update booking fields from normalized data.
|
||||
|
||||
Args:
|
||||
booking: Booking instance
|
||||
data: Normalized data dictionary
|
||||
"""
|
||||
# Direct field mappings
|
||||
if data.get("wp_kurs_id"):
|
||||
booking.wp_kurs_id = int(data["wp_kurs_id"])
|
||||
|
||||
if data.get("booking_number"):
|
||||
booking.booking_number = str(data["booking_number"])
|
||||
|
||||
if data.get("kurs_title"):
|
||||
booking.kurs_title = str(data["kurs_title"])
|
||||
|
||||
if data.get("kurs_location"):
|
||||
booking.kurs_location = str(data["kurs_location"])
|
||||
|
||||
if data.get("ticket_type"):
|
||||
booking.ticket_type = str(data["ticket_type"])
|
||||
|
||||
if data.get("ticket_count"):
|
||||
try:
|
||||
booking.ticket_count = int(data["ticket_count"])
|
||||
except (ValueError, TypeError):
|
||||
booking.ticket_count = 1
|
||||
|
||||
# Status with mapping
|
||||
if data.get("status"):
|
||||
status_raw = str(data["status"]).lower().strip()
|
||||
booking.status = cls.STATUS_MAPPINGS.get(status_raw, status_raw)
|
||||
|
||||
# Price parsing
|
||||
if data.get("total_price"):
|
||||
booking.total_price = cls._parse_price(data["total_price"])
|
||||
|
||||
# Date parsing
|
||||
if data.get("kurs_date"):
|
||||
booking.kurs_date = cls._parse_date(data["kurs_date"])
|
||||
|
||||
# Time fields
|
||||
if data.get("kurs_time"):
|
||||
booking.kurs_time = cls._parse_time(data["kurs_time"])
|
||||
if data.get("kurs_end_time"):
|
||||
booking.kurs_end_time = cls._parse_time(data["kurs_end_time"])
|
||||
|
||||
# Customer snapshot
|
||||
if data.get("customer_name"):
|
||||
booking.customer_name = str(data["customer_name"])
|
||||
if data.get("customer_email"):
|
||||
booking.customer_email = str(data["customer_email"])
|
||||
if data.get("customer_phone"):
|
||||
booking.customer_phone = str(data["customer_phone"])
|
||||
|
||||
# sevDesk
|
||||
if data.get("sevdesk_invoice_id"):
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
booking.sevdesk_invoice_id = int(data["sevdesk_invoice_id"])
|
||||
if data.get("sevdesk_invoice_number"):
|
||||
booking.sevdesk_invoice_number = str(data["sevdesk_invoice_number"])
|
||||
|
||||
# WordPress created date
|
||||
if data.get("wp_created_at"):
|
||||
wp_created = cls._parse_datetime(data["wp_created_at"])
|
||||
if wp_created:
|
||||
booking.wp_created_at = wp_created
|
||||
|
||||
# Custom fields
|
||||
if data.get("custom_fields"):
|
||||
existing = booking.custom_fields or {}
|
||||
existing.update(data["custom_fields"])
|
||||
booking.custom_fields = existing
|
||||
|
||||
@classmethod
|
||||
def _parse_price(cls, value: Any) -> Decimal | None:
|
||||
"""Parse price value to Decimal.
|
||||
|
||||
Handles German format (1.234,56) and English format (1,234.56)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
return Decimal(str(value))
|
||||
|
||||
# String parsing
|
||||
price_str = str(value).strip()
|
||||
|
||||
# Remove currency symbols and whitespace
|
||||
for char in ["EUR", "€", "$", " "]:
|
||||
price_str = price_str.replace(char, "")
|
||||
|
||||
if not price_str:
|
||||
return None
|
||||
|
||||
# Detect format and normalize
|
||||
# German: 1.234,56 -> English: 1234.56
|
||||
if "," in price_str and "." in price_str:
|
||||
if price_str.rfind(",") > price_str.rfind("."):
|
||||
# German format: 1.234,56
|
||||
price_str = price_str.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
# English format: 1,234.56
|
||||
price_str = price_str.replace(",", "")
|
||||
elif "," in price_str:
|
||||
# Could be German decimal: 12,50
|
||||
price_str = price_str.replace(",", ".")
|
||||
|
||||
try:
|
||||
return Decimal(price_str)
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _parse_date(cls, value: Any):
|
||||
"""Parse date value to date object."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if hasattr(value, "date"):
|
||||
return value.date() if hasattr(value, "date") else value
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
|
||||
# Try various formats
|
||||
formats = [
|
||||
"%Y-%m-%d", # ISO: 2024-01-15
|
||||
"%d.%m.%Y", # German: 15.01.2024
|
||||
"%d/%m/%Y", # European: 15/01/2024
|
||||
"%m/%d/%Y", # US: 01/15/2024
|
||||
"%Y-%m-%d %H:%M:%S", # ISO with time
|
||||
"%d.%m.%Y %H:%M", # German with time
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _parse_time(cls, value: Any) -> str | None:
|
||||
"""Parse time value to HH:MM string."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if hasattr(value, "strftime"):
|
||||
return value.strftime("%H:%M")
|
||||
|
||||
time_str = str(value).strip()
|
||||
|
||||
# Already in correct format
|
||||
if len(time_str) == 5 and time_str[2] == ":":
|
||||
return time_str
|
||||
|
||||
# Handle H:MM format
|
||||
if len(time_str) == 4 and time_str[1] == ":":
|
||||
return f"0{time_str}"
|
||||
|
||||
# Handle HHMM format
|
||||
if len(time_str) == 4 and time_str.isdigit():
|
||||
return f"{time_str[:2]}:{time_str[2:]}"
|
||||
|
||||
return time_str[:5] if len(time_str) >= 5 else time_str
|
||||
|
||||
@classmethod
|
||||
def _parse_datetime(cls, value: Any):
|
||||
"""Parse datetime value."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
|
||||
formats = [
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%d.%m.%Y %H:%M:%S",
|
||||
"%d.%m.%Y %H:%M",
|
||||
"%Y-%m-%d",
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_import_template_csv(cls) -> str:
|
||||
"""Generate CSV template for import.
|
||||
|
||||
Returns:
|
||||
CSV template string
|
||||
"""
|
||||
headers = [
|
||||
"buchungsnummer",
|
||||
"email",
|
||||
"name",
|
||||
"telefon",
|
||||
"kurs",
|
||||
"datum",
|
||||
"uhrzeit",
|
||||
"ort",
|
||||
"status",
|
||||
"preis",
|
||||
"ticket_typ",
|
||||
"anzahl",
|
||||
]
|
||||
return ";".join(headers) + "\n"
|
||||
|
||||
@classmethod
|
||||
def get_import_template_json(cls) -> str:
|
||||
"""Generate JSON template for import.
|
||||
|
||||
Returns:
|
||||
JSON template string
|
||||
"""
|
||||
template = {
|
||||
"bookings": [
|
||||
{
|
||||
"id": 1234,
|
||||
"number": "KB-2024-0001",
|
||||
"customer": {
|
||||
"email": "kunde@example.com",
|
||||
"name": "Max Mustermann",
|
||||
"phone": "+43 123 456789",
|
||||
},
|
||||
"kurs_title": "Beispielkurs",
|
||||
"kurs_date": "2024-01-15",
|
||||
"kurs_time": "10:00",
|
||||
"kurs_location": "Wien",
|
||||
"status": "confirmed",
|
||||
"price": 150.00,
|
||||
"ticket_type": "Standard",
|
||||
"ticket_count": 1,
|
||||
"custom_fields": {
|
||||
"Zusatzfeld 1": "Wert 1",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
return json.dumps(template, indent=2, ensure_ascii=False)
|
||||
237
customer_portal/services/email.py
Executable file
237
customer_portal/services/email.py
Executable file
@@ -0,0 +1,237 @@
|
||||
"""Email service.
|
||||
|
||||
Uses database settings from Admin panel instead of environment variables.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import UTC, datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from flask import current_app, render_template, url_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_mail_config():
|
||||
"""Get mail configuration from database.
|
||||
|
||||
Returns:
|
||||
dict: Mail configuration from database or defaults
|
||||
"""
|
||||
try:
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.settings import PortalSettings
|
||||
|
||||
db = get_db()
|
||||
return PortalSettings.get_mail_config(db)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get mail config from database: {e}")
|
||||
# Fallback to app config (environment variables)
|
||||
return {
|
||||
"mail_server": current_app.config.get("MAIL_SERVER", ""),
|
||||
"mail_port": current_app.config.get("MAIL_PORT", 587),
|
||||
"mail_use_tls": current_app.config.get("MAIL_USE_TLS", True),
|
||||
"mail_use_ssl": current_app.config.get("MAIL_USE_SSL", False),
|
||||
"mail_username": current_app.config.get("MAIL_USERNAME", ""),
|
||||
"mail_password": current_app.config.get("MAIL_PASSWORD", ""),
|
||||
"mail_default_sender": current_app.config.get("MAIL_DEFAULT_SENDER", ""),
|
||||
"mail_default_sender_name": "Kundenportal",
|
||||
}
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html_body: str, text_body: str = "") -> bool:
|
||||
"""Send email using database SMTP settings.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
html_body: HTML content
|
||||
text_body: Plain text content (optional)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
config = get_mail_config()
|
||||
|
||||
if not config.get("mail_server"):
|
||||
logger.error("Mail server not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["To"] = to
|
||||
|
||||
# Build sender
|
||||
sender_name = config.get("mail_default_sender_name", "Kundenportal")
|
||||
sender_email = config.get("mail_default_sender", "")
|
||||
if sender_name and sender_email:
|
||||
msg["From"] = f"{sender_name} <{sender_email}>"
|
||||
else:
|
||||
msg["From"] = sender_email
|
||||
|
||||
# Add plain text
|
||||
if text_body:
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
# Add HTML
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# Connect to SMTP server
|
||||
server = config.get("mail_server")
|
||||
port = config.get("mail_port", 587)
|
||||
use_ssl = config.get("mail_use_ssl", False)
|
||||
use_tls = config.get("mail_use_tls", True)
|
||||
|
||||
if use_ssl:
|
||||
smtp = smtplib.SMTP_SSL(server, port)
|
||||
else:
|
||||
smtp = smtplib.SMTP(server, port)
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
|
||||
# Login if credentials provided
|
||||
username = config.get("mail_username")
|
||||
password = config.get("mail_password")
|
||||
if username and password:
|
||||
smtp.login(username, password)
|
||||
|
||||
# Send
|
||||
smtp.sendmail(sender_email, [to], msg.as_string())
|
||||
smtp.quit()
|
||||
|
||||
logger.info(f"Email sent to {to}: {subject}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Send emails using database SMTP settings."""
|
||||
|
||||
SUBJECT_MAP = {
|
||||
"login": "Ihr Login-Code fuer das Kundenportal",
|
||||
"register": "Willkommen - Ihre Registrierung",
|
||||
"reset": "Ihr Code zum Zuruecksetzen",
|
||||
"prefill": "Ihr Code fuer die Buchung",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def send_otp(email: str, code: str, purpose: str = "login") -> bool:
|
||||
"""Send OTP code via email.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
code: OTP code
|
||||
purpose: OTP purpose (login, register, reset, prefill)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
subject = EmailService.SUBJECT_MAP.get(purpose, "Ihr Code")
|
||||
|
||||
html_body = render_template(
|
||||
"emails/otp.html",
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
text_body = f"""
|
||||
Ihr Code: {code}
|
||||
|
||||
Dieser Code ist 10 Minuten gueltig.
|
||||
|
||||
Falls Sie diesen Code nicht angefordert haben, ignorieren Sie diese E-Mail.
|
||||
"""
|
||||
|
||||
success = send_email(email, subject, html_body, text_body)
|
||||
|
||||
if success:
|
||||
logger.info(f"OTP email sent to {email} for {purpose}")
|
||||
# In development, log the code for testing
|
||||
elif current_app.debug:
|
||||
logger.warning(f"DEBUG MODE - OTP code for {email}: {code}")
|
||||
|
||||
return success
|
||||
|
||||
@staticmethod
|
||||
def send_login_notification(email: str, ip_address: str, user_agent: str) -> bool:
|
||||
"""Send notification about new login.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
ip_address: Login IP address
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
html_body = render_template(
|
||||
"emails/login_notification.html",
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return send_email(email, "Neue Anmeldung in Ihrem Kundenportal", html_body)
|
||||
|
||||
@staticmethod
|
||||
def send_welcome(email: str, name: str) -> bool:
|
||||
"""Send welcome email after registration.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
name: Customer name
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
# Get portal URL from config or build it
|
||||
portal_url = current_app.config.get("PORTAL_URL", "")
|
||||
if not portal_url:
|
||||
try:
|
||||
portal_url = url_for("main.index", _external=True)
|
||||
except RuntimeError:
|
||||
portal_url = "http://localhost:8502"
|
||||
|
||||
html_body = render_template(
|
||||
"emails/welcome.html",
|
||||
name=name,
|
||||
portal_url=portal_url,
|
||||
year=datetime.now(UTC).year,
|
||||
)
|
||||
|
||||
text_body = f"""
|
||||
Willkommen, {name}!
|
||||
|
||||
Ihr Kundenkonto wurde erfolgreich erstellt.
|
||||
|
||||
Im Kundenportal koennen Sie:
|
||||
- Ihre Buchungen einsehen
|
||||
- Rechnungen herunterladen
|
||||
- Videos ansehen
|
||||
- Stornierungen beantragen
|
||||
|
||||
Zum Portal: {portal_url}
|
||||
"""
|
||||
|
||||
success = send_email(
|
||||
email, "Willkommen im Kundenportal - Webwerkstatt", html_body, text_body
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Welcome email sent to {email}")
|
||||
elif current_app.debug:
|
||||
logger.warning(f"DEBUG MODE - Welcome email would be sent to {email}")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
# Keep Flask-Mail for backwards compatibility (but not used anymore)
|
||||
from flask_mail import Mail
|
||||
|
||||
mail = Mail()
|
||||
109
customer_portal/services/otp.py
Executable file
109
customer_portal/services/otp.py
Executable file
@@ -0,0 +1,109 @@
|
||||
"""OTP service."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from customer_portal.models.otp import OTPCode
|
||||
|
||||
|
||||
class OTPService:
|
||||
"""Generate and verify OTP codes."""
|
||||
|
||||
OTP_LENGTH = 6
|
||||
OTP_LIFETIME_MINUTES = 10
|
||||
|
||||
@staticmethod
|
||||
def generate() -> str:
|
||||
"""Generate random 6-digit OTP."""
|
||||
return "".join(
|
||||
secrets.choice("0123456789") for _ in range(OTPService.OTP_LENGTH)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_for_customer(db_session, customer_id: int, purpose: str) -> str:
|
||||
"""Create OTP for customer.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
purpose: OTP purpose (login, register, reset)
|
||||
|
||||
Returns:
|
||||
Generated OTP code
|
||||
"""
|
||||
# Invalidate any existing unused OTPs for same purpose
|
||||
db_session.query(OTPCode).filter(
|
||||
OTPCode.customer_id == customer_id,
|
||||
OTPCode.purpose == purpose,
|
||||
OTPCode.used_at.is_(None),
|
||||
).update({"used_at": datetime.now(UTC)})
|
||||
|
||||
code = OTPService.generate()
|
||||
expires_at = datetime.now(UTC) + timedelta(
|
||||
minutes=OTPService.OTP_LIFETIME_MINUTES
|
||||
)
|
||||
|
||||
otp = OTPCode(
|
||||
customer_id=customer_id,
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db_session.add(otp)
|
||||
db_session.commit()
|
||||
|
||||
return code
|
||||
|
||||
@staticmethod
|
||||
def verify(db_session, customer_id: int, code: str, purpose: str) -> bool:
|
||||
"""Verify OTP code.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
code: OTP code to verify
|
||||
purpose: OTP purpose
|
||||
|
||||
Returns:
|
||||
True if code is valid, False otherwise
|
||||
"""
|
||||
otp = (
|
||||
db_session.query(OTPCode)
|
||||
.filter(
|
||||
OTPCode.customer_id == customer_id,
|
||||
OTPCode.code == code,
|
||||
OTPCode.purpose == purpose,
|
||||
OTPCode.used_at.is_(None),
|
||||
OTPCode.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if otp:
|
||||
otp.used_at = datetime.now(UTC)
|
||||
db_session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def count_recent_attempts(db_session, customer_id: int, minutes: int = 60) -> int:
|
||||
"""Count recent OTP attempts for rate limiting.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
minutes: Time window in minutes
|
||||
|
||||
Returns:
|
||||
Number of OTPs created in time window
|
||||
"""
|
||||
since = datetime.now(UTC) - timedelta(minutes=minutes)
|
||||
return (
|
||||
db_session.query(OTPCode)
|
||||
.filter(
|
||||
OTPCode.customer_id == customer_id,
|
||||
OTPCode.created_at >= since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
147
customer_portal/services/token.py
Executable file
147
customer_portal/services/token.py
Executable file
@@ -0,0 +1,147 @@
|
||||
"""Token service for secure form pre-filling.
|
||||
|
||||
Generates signed tokens for cross-system communication between
|
||||
Customer Portal and WordPress kurs-booking plugin.
|
||||
|
||||
Sprint 6.6: Extended to include all custom_fields for dynamic sync.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""Generate and validate signed tokens for cross-system communication."""
|
||||
|
||||
@staticmethod
|
||||
def generate_prefill_token(customer: Any) -> str:
|
||||
"""
|
||||
Generate signed token for WordPress form pre-fill.
|
||||
|
||||
The token contains all customer data (core fields + custom_fields)
|
||||
signed with the shared WP_API_SECRET. WordPress validates the
|
||||
signature and uses the data to pre-fill the booking form.
|
||||
|
||||
Sprint 6.6: Now includes all custom_fields for complete sync.
|
||||
|
||||
Args:
|
||||
customer: Customer model instance with id, name, email, phone, custom_fields
|
||||
|
||||
Returns:
|
||||
Base64-URL-safe encoded signed token string
|
||||
|
||||
Raises:
|
||||
ValueError: If WP_API_SECRET is not configured
|
||||
"""
|
||||
secret = current_app.config.get("WP_API_SECRET", "")
|
||||
expiry = current_app.config.get("PREFILL_TOKEN_EXPIRY", 300)
|
||||
|
||||
if not secret:
|
||||
raise ValueError("WP_API_SECRET not configured")
|
||||
|
||||
# Sprint 12: Use display properties - all data from custom_fields
|
||||
addr = customer.display_address
|
||||
payload = {
|
||||
"customer_id": customer.id,
|
||||
"name": customer.display_name,
|
||||
"email": customer.email or "",
|
||||
"phone": customer.display_phone,
|
||||
"address_street": addr.get("street", ""),
|
||||
"address_city": addr.get("city", ""),
|
||||
"address_zip": addr.get("zip", ""),
|
||||
"exp": int(time.time()) + expiry,
|
||||
}
|
||||
|
||||
# Add all custom_fields to payload for full data access
|
||||
custom_fields = customer.get_custom_fields()
|
||||
if custom_fields:
|
||||
payload["custom_fields"] = custom_fields
|
||||
|
||||
# JSON encode with minimal whitespace.
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
# Create HMAC-SHA256 signature.
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
# Combine payload and signature.
|
||||
token_data = f"{payload_json}.{signature}"
|
||||
|
||||
# Base64 URL-safe encode.
|
||||
return base64.urlsafe_b64encode(token_data.encode("utf-8")).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def validate_prefill_token(token: str) -> dict | None:
|
||||
"""
|
||||
Validate a prefill token and extract customer data.
|
||||
|
||||
This method is primarily for testing purposes. The actual validation
|
||||
happens in WordPress (class-frontend.php).
|
||||
|
||||
Args:
|
||||
token: Base64-URL-safe encoded signed token
|
||||
|
||||
Returns:
|
||||
Dictionary with customer data if valid, None otherwise
|
||||
"""
|
||||
secret = current_app.config.get("WP_API_SECRET", "")
|
||||
|
||||
if not secret:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decode base64.
|
||||
decoded = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
# Split payload and signature.
|
||||
parts = decoded.split(".", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
payload_json, signature = parts
|
||||
|
||||
# Verify signature.
|
||||
expected_sig = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(expected_sig, signature):
|
||||
return None
|
||||
|
||||
# Parse payload.
|
||||
payload = json.loads(payload_json)
|
||||
|
||||
# Check expiration.
|
||||
if payload.get("exp", 0) < time.time():
|
||||
return None
|
||||
|
||||
# Sprint 6.6: Return all fields including custom_fields
|
||||
result = {
|
||||
"customer_id": payload.get("customer_id"),
|
||||
"name": payload.get("name", ""),
|
||||
"email": payload.get("email", ""),
|
||||
"phone": payload.get("phone", ""),
|
||||
"address_street": payload.get("address_street", ""),
|
||||
"address_city": payload.get("address_city", ""),
|
||||
"address_zip": payload.get("address_zip", ""),
|
||||
}
|
||||
|
||||
# Include custom_fields if present
|
||||
if "custom_fields" in payload:
|
||||
result["custom_fields"] = payload["custom_fields"]
|
||||
|
||||
return result
|
||||
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
555
customer_portal/services/wordpress_api.py
Executable file
555
customer_portal/services/wordpress_api.py
Executable file
@@ -0,0 +1,555 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user