Initial commit - Customer Portal for Coolify

This commit is contained in:
2025-12-17 10:08:34 +01:00
commit 9fca32567c
153 changed files with 16432 additions and 0 deletions

View 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

128
customer_portal/services/auth.py Executable file
View 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

View 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
View 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
View 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
View 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

View 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