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

61
.dockerignore Normal file
View File

@@ -0,0 +1,61 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.env
.venv
venv/
ENV/
.eggs
*.egg-info/
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Docker
Dockerfile*
!Dockerfile
docker-compose*.yml
.docker/
# Documentation
*.md
docs/
LICENSE
# Tests
tests/
test_*.py
*_test.py
conftest.py
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
Jenkinsfile
# Database
*.db
*.sqlite3
migrations/versions/*.pyc
# Misc
*.log
*.tmp
.DS_Store
Thumbs.db

81
Dockerfile Normal file
View File

@@ -0,0 +1,81 @@
# Customer Portal - Bulletproof Production Dockerfile
# Security-hardened, multi-stage build
# =============================================================================
# Stage 1: Build dependencies
# =============================================================================
FROM python:3.13-slim-bookworm AS builder
WORKDIR /build
# Install build-time dependencies (not in final image)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment for clean dependency isolation
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir --no-warn-script-location -r requirements.txt
# =============================================================================
# Stage 2: Production image
# =============================================================================
FROM python:3.13-slim-bookworm
# OCI Labels
LABEL org.opencontainers.image.title="Customer Portal"
LABEL org.opencontainers.image.description="Customer portal for Kurs-Booking"
LABEL org.opencontainers.image.vendor="webideas24"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.source="https://git.islandpferde-melanieworbs.de/webideas24/customer-portal"
# Security: Install tini and minimal runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
libpq5 \
curl \
ca-certificates \
postgresql-client \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm -rf /root/.cache
WORKDIR /app
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
# Security: Create non-root user with no shell
RUN groupadd -r -g 1000 portal && \
useradd -r -u 1000 -g portal -s /usr/sbin/nologin -d /home/portal portal && \
mkdir -p /home/portal && \
chown -R portal:portal /app /home/portal
# Copy application and entrypoint
COPY --chown=portal:portal customer_portal/ customer_portal/
COPY --chown=portal:portal entrypoint.sh /app/entrypoint.sh
RUN chmod 755 /app/entrypoint.sh
# Switch to non-root user
USER portal
# Environment
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
FLASK_ENV=production \
PIP_NO_CACHE_DIR=1
EXPOSE 8000
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
# Use tini as init, entrypoint handles migrations
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]

68
Dockerfile.production Executable file
View File

@@ -0,0 +1,68 @@
# Production Dockerfile for Customer Portal
# Multi-stage build for smaller image size
# Stage 1: Build dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Production image
FROM python:3.12-slim
WORKDIR /app
# Install runtime dependencies only
RUN apt-get update && apt-get install -y \
libpq5 \
curl \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application code (includes migrations in customer_portal/migrations/)
COPY customer_portal/ customer_portal/
# Copy and prepare entrypoint
COPY entrypoint.sh /app/entrypoint.sh
# Create non-root user and set permissions
RUN useradd -m -r portal && \
chown -R portal:portal /app && \
chmod +x /app/entrypoint.sh
USER portal
# Environment
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV FLASK_ENV=production
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Entrypoint handles:
# 1. Wait for PostgreSQL
# 2. Run flask db upgrade
# 3. Start Gunicorn
ENTRYPOINT ["/app/entrypoint.sh"]

3
customer_portal/__init__.py Executable file
View File

@@ -0,0 +1,3 @@
"""Kundenportal for Webwerkstatt."""
__version__ = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

59
customer_portal/config.py Executable file
View File

@@ -0,0 +1,59 @@
"""Application configuration."""
import os
class Config:
"""Base configuration."""
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
# Database
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL",
"postgresql://portal:portal@localhost:5433/customer_portal",
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Email
MAIL_SERVER = os.getenv("MAIL_SERVER", "localhost")
MAIL_PORT = int(os.getenv("MAIL_PORT", "587"))
MAIL_USE_TLS = os.getenv("MAIL_USE_TLS", "true").lower() == "true"
MAIL_USERNAME = os.getenv("MAIL_USERNAME")
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER")
# External APIs
WP_API_URL = os.getenv("WP_API_URL")
WP_API_SECRET = os.getenv("WP_API_SECRET")
VIDEO_API_URL = os.getenv("VIDEO_API_URL")
# Portal URL for emails
PORTAL_URL = os.getenv("PORTAL_URL", "http://localhost:8502")
# Session
SESSION_LIFETIME_HOURS = 24
OTP_LIFETIME_MINUTES = 10
OTP_MAX_ATTEMPTS = 5
# Form Pre-fill Token (for WordPress booking form)
PREFILL_TOKEN_EXPIRY = int(os.getenv("PREFILL_TOKEN_EXPIRY", "300")) # 5 minutes
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
config = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}

View File

@@ -0,0 +1,103 @@
"""Migration: Add custom_fields column to customers table.
Sprint 6.6: Enable dynamic WordPress field synchronization.
This migration adds a TEXT column for storing JSON-encoded custom fields
from WordPress booking forms, enabling automatic synchronization without
schema changes.
Usage:
python -m customer_portal.migrations.001_add_custom_fields
"""
import os
import sys
from sqlalchemy import create_engine, inspect, text
def get_database_url() -> str:
"""Get database URL from environment."""
return os.getenv(
"DATABASE_URL",
os.getenv(
"SQLALCHEMY_DATABASE_URI",
"sqlite:///customer_portal.db",
),
)
def migrate():
"""Add custom_fields column to customers table."""
database_url = get_database_url()
engine = create_engine(database_url)
inspector = inspect(engine)
columns = [col["name"] for col in inspector.get_columns("customers")]
if "custom_fields" in columns:
print("Column 'custom_fields' already exists. Skipping.")
return True
print("Adding 'custom_fields' column to customers table...")
# Detect database type for appropriate SQL
dialect = engine.dialect.name
with engine.begin() as conn:
if dialect in {"sqlite", "postgresql"}:
conn.execute(
text("ALTER TABLE customers ADD COLUMN custom_fields TEXT DEFAULT '{}'")
)
elif dialect == "mysql":
conn.execute(text("ALTER TABLE customers ADD COLUMN custom_fields TEXT"))
conn.execute(
text(
"UPDATE customers SET custom_fields = '{}' WHERE custom_fields IS NULL"
)
)
else:
# Generic SQL
conn.execute(
text("ALTER TABLE customers ADD COLUMN custom_fields TEXT DEFAULT '{}'")
)
print("Migration completed successfully.")
return True
def rollback():
"""Remove custom_fields column (if needed)."""
database_url = get_database_url()
engine = create_engine(database_url)
inspector = inspect(engine)
columns = [col["name"] for col in inspector.get_columns("customers")]
if "custom_fields" not in columns:
print("Column 'custom_fields' does not exist. Skipping rollback.")
return True
print("WARNING: Rolling back will DELETE all custom field data!")
dialect = engine.dialect.name
with engine.begin() as conn:
if dialect == "sqlite":
# SQLite doesn't support DROP COLUMN directly
print("SQLite does not support DROP COLUMN. Manual migration required.")
return False
else:
conn.execute(text("ALTER TABLE customers DROP COLUMN custom_fields"))
print("Rollback completed.")
return True
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--rollback":
success = rollback()
else:
success = migrate()
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,101 @@
"""Add admin_users table and portal_settings table.
Run with:
docker exec customer_portal python -m customer_portal.migrations.002_add_admin_and_settings
"""
import os
from sqlalchemy import create_engine, text
def run_migration():
"""Create admin_users and portal_settings tables."""
database_url = os.environ.get(
"DATABASE_URL", "postgresql://portal:portal@localhost:5432/customer_portal"
)
engine = create_engine(database_url)
with engine.connect() as conn:
# Check if admin_users table exists
result = conn.execute(
text(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'admin_users'
"""
)
)
if not result.fetchone():
print("Creating admin_users table...")
conn.execute(
text(
"""
CREATE TABLE admin_users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX ix_admin_users_username ON admin_users(username)
"""
)
)
conn.commit()
print("Table admin_users created successfully.")
else:
print("Table admin_users already exists.")
# Check if portal_settings table exists
result = conn.execute(
text(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'portal_settings'
"""
)
)
if not result.fetchone():
print("Creating portal_settings table...")
conn.execute(
text(
"""
CREATE TABLE portal_settings (
id SERIAL PRIMARY KEY,
key VARCHAR(100) UNIQUE NOT NULL,
value TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX ix_portal_settings_key ON portal_settings(key)
"""
)
)
conn.commit()
print("Table portal_settings created successfully.")
else:
print("Table portal_settings already exists.")
print("Migration completed successfully!")
if __name__ == "__main__":
run_migration()

View File

@@ -0,0 +1,75 @@
"""Add email preference columns to customers.
Run with:
docker exec customer_portal python -m customer_portal.migrations.003_add_email_preferences
"""
import os
from sqlalchemy import create_engine, text
def run_migration():
"""Add email_invoices and email_marketing columns."""
database_url = os.environ.get(
"DATABASE_URL", "postgresql://portal:portal@localhost:5432/customer_portal"
)
engine = create_engine(database_url)
with engine.connect() as conn:
# Check and add email_invoices column
result = conn.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'customers' AND column_name = 'email_invoices'
"""
)
)
if not result.fetchone():
print("Adding email_invoices column...")
conn.execute(
text(
"""
ALTER TABLE customers
ADD COLUMN email_invoices BOOLEAN DEFAULT TRUE
"""
)
)
conn.commit()
print("Column email_invoices added.")
else:
print("Column email_invoices already exists.")
# Check and add email_marketing column
result = conn.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'customers' AND column_name = 'email_marketing'
"""
)
)
if not result.fetchone():
print("Adding email_marketing column...")
conn.execute(
text(
"""
ALTER TABLE customers
ADD COLUMN email_marketing BOOLEAN DEFAULT FALSE
"""
)
)
conn.commit()
print("Column email_marketing added.")
else:
print("Column email_marketing already exists.")
print("Migration completed!")
if __name__ == "__main__":
run_migration()

View File

@@ -0,0 +1,259 @@
"""Consolidate fixed customer fields into custom_fields JSON.
Sprint 12 Phase 1: Move name, phone, address fields into custom_fields.
This eliminates redundancy between fixed columns and flexible JSON storage.
Run with:
docker exec customer_portal python -m customer_portal.migrations.004_consolidate_customer_fields
Rollback with:
docker exec customer_portal python -m customer_portal.migrations.004_consolidate_customer_fields --rollback
"""
import json
import os
import sys
from sqlalchemy import create_engine, text
def get_engine():
"""Get database engine."""
database_url = os.environ.get(
"DATABASE_URL", "postgresql://portal:portal@localhost:5432/customer_portal"
)
return create_engine(database_url)
def run_migration():
"""Move fixed field values into custom_fields JSON."""
engine = get_engine()
print("Sprint 12 Phase 1: Consolidating customer fields...")
print("=" * 60)
with engine.connect() as conn:
# Get all customers
result = conn.execute(
text(
"""
SELECT id, email, name, phone, address_street, address_city, address_zip, custom_fields
FROM customers
ORDER BY id
"""
)
)
customers = result.fetchall()
migrated = 0
skipped = 0
for row in customers:
customer_id = row[0]
email = row[1]
name = row[2]
phone = row[3]
address_street = row[4]
address_city = row[5]
address_zip = row[6]
custom_fields_raw = row[7]
# Parse existing custom_fields
try:
custom_fields = (
json.loads(custom_fields_raw) if custom_fields_raw else {}
)
except (json.JSONDecodeError, TypeError):
custom_fields = {}
# Track changes
changes = []
# Migrate name (only if not already in custom_fields)
if name and "name" not in custom_fields:
custom_fields["name"] = name
changes.append(f"name={name}")
# Migrate phone (only if not already in custom_fields)
if phone and "phone" not in custom_fields:
custom_fields["phone"] = phone
changes.append(f"phone={phone}")
# Migrate address_street (only if not already in custom_fields)
if address_street and "address_street" not in custom_fields:
custom_fields["address_street"] = address_street
changes.append(f"address_street={address_street[:30]}...")
# Migrate address_city (only if not already in custom_fields)
if address_city and "address_city" not in custom_fields:
custom_fields["address_city"] = address_city
changes.append(f"address_city={address_city[:30]}...")
# Migrate address_zip (only if not already in custom_fields)
if address_zip and "address_zip" not in custom_fields:
custom_fields["address_zip"] = address_zip
changes.append(f"address_zip={address_zip}")
if changes:
# Update customer
new_custom_fields = json.dumps(custom_fields, ensure_ascii=False)
conn.execute(
text(
"""
UPDATE customers
SET custom_fields = :custom_fields
WHERE id = :id
"""
),
{"custom_fields": new_custom_fields, "id": customer_id},
)
migrated += 1
print(f" [OK] Customer {customer_id} ({email}): {', '.join(changes)}")
else:
skipped += 1
conn.commit()
print("=" * 60)
print(
f"Migration completed: {migrated} migrated, {skipped} skipped (already consolidated)"
)
def run_rollback():
"""Restore fixed fields from custom_fields (for rollback)."""
engine = get_engine()
print("Rolling back Sprint 12 Phase 1...")
print("=" * 60)
with engine.connect() as conn:
# Get all customers
result = conn.execute(
text(
"""
SELECT id, email, name, phone, address_street, address_city, address_zip, custom_fields
FROM customers
ORDER BY id
"""
)
)
customers = result.fetchall()
rolled_back = 0
for row in customers:
customer_id = row[0]
email = row[1]
current_name = row[2]
current_phone = row[3]
current_street = row[4]
current_city = row[5]
current_zip = row[6]
custom_fields_raw = row[7]
# Parse custom_fields
try:
custom_fields = (
json.loads(custom_fields_raw) if custom_fields_raw else {}
)
except (json.JSONDecodeError, TypeError):
continue
# Check if we have migrated data in custom_fields
updates = {}
# Restore name if it differs
if "name" in custom_fields and custom_fields["name"] != current_name:
updates["name"] = custom_fields["name"]
# Restore phone if it differs
if "phone" in custom_fields and custom_fields["phone"] != current_phone:
updates["phone"] = custom_fields["phone"]
# Restore address fields
if (
"address_street" in custom_fields
and custom_fields["address_street"] != current_street
):
updates["address_street"] = custom_fields["address_street"]
if (
"address_city" in custom_fields
and custom_fields["address_city"] != current_city
):
updates["address_city"] = custom_fields["address_city"]
if (
"address_zip" in custom_fields
and custom_fields["address_zip"] != current_zip
):
updates["address_zip"] = custom_fields["address_zip"]
if updates:
# Build update query
set_clauses = ", ".join([f"{k} = :{k}" for k in updates])
updates["id"] = customer_id
conn.execute(
text(f"UPDATE customers SET {set_clauses} WHERE id = :id"), updates
)
rolled_back += 1
print(
f" [OK] Customer {customer_id} ({email}): restored {list(updates.keys())}"
)
conn.commit()
print("=" * 60)
print(f"Rollback completed: {rolled_back} customers restored")
def verify_migration():
"""Verify migration by checking custom_fields content."""
engine = get_engine()
print("\nVerifying migration...")
print("=" * 60)
with engine.connect() as conn:
result = conn.execute(
text(
"""
SELECT id, email, custom_fields
FROM customers
WHERE custom_fields IS NOT NULL AND custom_fields != '{}'
ORDER BY id
LIMIT 10
"""
)
)
for row in result:
customer_id = row[0]
email = row[1]
custom_fields_raw = row[2]
try:
custom_fields = json.loads(custom_fields_raw)
has_name = "name" in custom_fields
has_phone = "phone" in custom_fields
has_address = "address_street" in custom_fields
print(f" Customer {customer_id} ({email}):")
print(f" name: {'OK' if has_name else 'MISSING'}")
print(f" phone: {'OK' if has_phone else 'MISSING'}")
print(f" address: {'OK' if has_address else 'MISSING'}")
except json.JSONDecodeError:
print(f" Customer {customer_id} ({email}): INVALID JSON")
print("=" * 60)
if __name__ == "__main__":
if "--rollback" in sys.argv:
run_rollback()
elif "--verify" in sys.argv:
verify_migration()
else:
run_migration()
verify_migration()

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Migration 005: Convert custom_fields from TEXT to JSONB.
Sprint 12: PostgreSQL JSONB enables:
- Native JSON operators (@>, ?, ?|, ?&, etc.)
- GIN index for fast searching
- Automatic JSON validation
Usage:
python -m customer_portal.migrations.005_text_to_jsonb
The migration:
1. Converts existing TEXT JSON to JSONB
2. Creates GIN index for searching
"""
import os
import sys
from sqlalchemy import create_engine, text
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
def get_database_url() -> str:
"""Get database URL from environment or default."""
return os.environ.get(
"DATABASE_URL", "postgresql://portal:portal@localhost:5433/customer_portal"
)
def run_migration():
"""Convert custom_fields from TEXT to JSONB."""
engine = create_engine(get_database_url())
with engine.connect() as conn:
# Check current column type
result = conn.execute(
text(
"""
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'customers'
AND column_name = 'custom_fields'
"""
)
)
row = result.fetchone()
if not row:
print("Column 'custom_fields' not found in 'customers' table")
return
current_type = row[0]
print(f"Current column type: {current_type}")
if current_type == "jsonb":
print("Column is already JSONB - skipping conversion")
else:
print("Converting TEXT to JSONB...")
# Step 1: Drop the default first (can't cast default automatically)
conn.execute(
text(
"""
ALTER TABLE customers
ALTER COLUMN custom_fields DROP DEFAULT
"""
)
)
# Step 2: Update NULL/empty values to valid JSON
conn.execute(
text(
"""
UPDATE customers
SET custom_fields = '{}'
WHERE custom_fields IS NULL OR custom_fields = ''
"""
)
)
# Step 3: Convert TEXT to JSONB
conn.execute(
text(
"""
ALTER TABLE customers
ALTER COLUMN custom_fields
TYPE JSONB
USING custom_fields::jsonb
"""
)
)
# Step 4: Set new default and NOT NULL
conn.execute(
text(
"""
ALTER TABLE customers
ALTER COLUMN custom_fields
SET DEFAULT '{}'::jsonb
"""
)
)
conn.execute(
text(
"""
ALTER TABLE customers
ALTER COLUMN custom_fields
SET NOT NULL
"""
)
)
print("Column converted to JSONB")
# Check if GIN index exists
result = conn.execute(
text(
"""
SELECT indexname
FROM pg_indexes
WHERE tablename = 'customers'
AND indexname = 'ix_customers_custom_fields'
"""
)
)
if result.fetchone():
print("GIN index already exists")
else:
print("Creating GIN index...")
conn.execute(
text(
"""
CREATE INDEX ix_customers_custom_fields
ON customers
USING GIN (custom_fields)
"""
)
)
print("GIN index created")
conn.commit()
print("\nMigration completed successfully!")
# Show example queries
print("\n--- Example JSONB Queries ---")
print("-- Find customers from Wien:")
print("SELECT * FROM customers WHERE custom_fields->>'ort' = 'Wien';")
print("")
print("-- Find customers with phone number:")
print("SELECT * FROM customers WHERE custom_fields ? 'telefon';")
print("")
print("-- Find customers with specific field value (uses GIN index):")
print('SELECT * FROM customers WHERE custom_fields @> \'{"ort": "Wien"}\';')
def rollback():
"""Rollback: Convert JSONB back to TEXT."""
engine = create_engine(get_database_url())
with engine.connect() as conn:
print("Rolling back: Converting JSONB to TEXT...")
# Drop GIN index first
conn.execute(
text(
"""
DROP INDEX IF EXISTS ix_customers_custom_fields
"""
)
)
# Convert JSONB to TEXT
conn.execute(
text(
"""
ALTER TABLE customers
ALTER COLUMN custom_fields
TYPE TEXT
USING custom_fields::TEXT
"""
)
)
conn.commit()
print("Rollback completed")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--rollback":
rollback()
else:
run_migration()

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Migration 006: Drop legacy customer columns.
Sprint 12 Final Cleanup:
All customer data is now in custom_fields (JSONB).
Remove the deprecated fixed columns.
Usage:
python -m customer_portal.migrations.006_drop_legacy_columns
IMPORTANT: Run migrations 004 and 005 first!
- 004: Consolidate data into custom_fields
- 005: Convert TEXT to JSONB
- 006: Drop legacy columns (this migration)
"""
import os
import sys
from sqlalchemy import create_engine, text
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
# Columns to remove
LEGACY_COLUMNS = ["name", "phone", "address_street", "address_city", "address_zip"]
def get_database_url() -> str:
"""Get database URL from environment or default."""
return os.environ.get(
"DATABASE_URL", "postgresql://portal:portal@localhost:5433/customer_portal"
)
def verify_data_migrated(conn) -> bool:
"""Verify all legacy data is in custom_fields before dropping columns."""
print("Verifying data migration...")
# Check for customers with legacy data not in custom_fields
result = conn.execute(
text(
"""
SELECT COUNT(*) as cnt
FROM customers
WHERE (
(name IS NOT NULL AND name != '' AND
(custom_fields->>'name' IS NULL OR custom_fields->>'name' = ''))
OR
(phone IS NOT NULL AND phone != '' AND
(custom_fields->>'phone' IS NULL OR custom_fields->>'phone' = ''))
OR
(address_street IS NOT NULL AND address_street != '' AND
(custom_fields->>'address_street' IS NULL OR custom_fields->>'address_street' = ''))
OR
(address_city IS NOT NULL AND address_city != '' AND
(custom_fields->>'address_city' IS NULL OR custom_fields->>'address_city' = ''))
OR
(address_zip IS NOT NULL AND address_zip != '' AND
(custom_fields->>'address_zip' IS NULL OR custom_fields->>'address_zip' = ''))
)
"""
)
)
count = result.fetchone()[0]
if count > 0:
print(
f" WARNING: {count} customers have data in legacy columns not in custom_fields!"
)
return False
print(" All legacy data is in custom_fields")
return True
def migrate_remaining_data(conn) -> int:
"""Migrate any remaining data from legacy columns to custom_fields."""
print("Migrating any remaining legacy data...")
result = conn.execute(
text(
"""
UPDATE customers
SET custom_fields = custom_fields ||
jsonb_strip_nulls(jsonb_build_object(
'name', CASE WHEN name != '' AND (custom_fields->>'name' IS NULL OR custom_fields->>'name' = '') THEN name ELSE NULL END,
'phone', CASE WHEN phone != '' AND (custom_fields->>'phone' IS NULL OR custom_fields->>'phone' = '') THEN phone ELSE NULL END,
'address_street', CASE WHEN address_street != '' AND (custom_fields->>'address_street' IS NULL OR custom_fields->>'address_street' = '') THEN address_street ELSE NULL END,
'address_city', CASE WHEN address_city != '' AND (custom_fields->>'address_city' IS NULL OR custom_fields->>'address_city' = '') THEN address_city ELSE NULL END,
'address_zip', CASE WHEN address_zip != '' AND (custom_fields->>'address_zip' IS NULL OR custom_fields->>'address_zip' = '') THEN address_zip ELSE NULL END
))
WHERE name IS NOT NULL OR phone IS NOT NULL OR address_street IS NOT NULL
OR address_city IS NOT NULL OR address_zip IS NOT NULL
"""
)
)
count = result.rowcount
print(f" Updated {count} customers")
return count
def check_columns_exist(conn) -> list[str]:
"""Check which legacy columns still exist."""
result = conn.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'customers'
AND column_name = ANY(:columns)
"""
),
{"columns": LEGACY_COLUMNS},
)
return [row[0] for row in result.fetchall()]
def run_migration():
"""Drop legacy columns after verifying data migration."""
engine = create_engine(get_database_url())
with engine.connect() as conn:
# Check which columns exist
existing = check_columns_exist(conn)
if not existing:
print("No legacy columns found - nothing to do")
return
print(f"Found legacy columns: {existing}")
# First, migrate any remaining data
migrate_remaining_data(conn)
conn.commit()
# Verify all data is migrated
if not verify_data_migrated(conn):
print("\nERROR: Data verification failed!")
print("Run migration 004 first to consolidate data.")
return
# Drop columns
print("\nDropping legacy columns...")
for col in existing:
print(f" Dropping: {col}")
conn.execute(text(f"ALTER TABLE customers DROP COLUMN IF EXISTS {col}"))
conn.commit()
print("\nMigration completed successfully!")
print("\nCustomer table now has only:")
print(" - id (PK)")
print(" - email (unique identifier)")
print(" - custom_fields (JSONB - all customer data)")
print(" - wp_user_id")
print(" - email_* preferences")
print(" - is_admin")
print(" - created_at, updated_at, last_login_at")
def rollback():
"""Rollback: Re-add legacy columns (data will be empty)."""
engine = create_engine(get_database_url())
with engine.connect() as conn:
print("Rolling back: Re-adding legacy columns...")
conn.execute(
text(
"""
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS name VARCHAR(255) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS address_street VARCHAR(255),
ADD COLUMN IF NOT EXISTS address_city VARCHAR(255),
ADD COLUMN IF NOT EXISTS address_zip VARCHAR(20)
"""
)
)
# Populate from custom_fields
print("Populating columns from custom_fields...")
conn.execute(
text(
"""
UPDATE customers
SET
name = COALESCE(custom_fields->>'name', ''),
phone = custom_fields->>'phone',
address_street = custom_fields->>'address_street',
address_city = custom_fields->>'address_city',
address_zip = custom_fields->>'address_zip'
"""
)
)
conn.commit()
print("Rollback completed")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--rollback":
rollback()
else:
run_migration()

View File

@@ -0,0 +1,7 @@
"""Database migrations for Customer Portal.
Sprint 6.6: Added custom_fields migration for WordPress sync.
Run migrations:
python -m customer_portal.migrations.001_add_custom_fields
"""

View File

@@ -0,0 +1,45 @@
"""Database models."""
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
Base = declarative_base()
engine = None
db_session = None
def init_db(database_url: str) -> None:
"""Initialize database connection."""
global engine, db_session
engine = create_engine(database_url)
session_factory = sessionmaker(bind=engine)
db_session = scoped_session(session_factory)
Base.query = db_session.query_property()
def get_db():
"""Get database session."""
return db_session
def close_db(exception=None):
"""Close database session."""
if db_session:
db_session.remove()
def create_tables():
"""Create all tables."""
from customer_portal.models import (
admin_user,
booking,
customer,
otp,
session,
settings,
)
Base.metadata.create_all(bind=engine)

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,72 @@
"""Admin User model with separate authentication."""
from datetime import UTC, datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from werkzeug.security import check_password_hash, generate_password_hash
from customer_portal.models import Base
class AdminUser(Base):
"""Admin user with username/password authentication.
Separate from Customer - admins have their own login.
"""
__tablename__ = "admin_users"
id = Column(Integer, primary_key=True)
username = Column(String(100), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
email = Column(String(255))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
last_login_at = Column(DateTime)
def __repr__(self) -> str:
return f"<AdminUser {self.username}>"
def set_password(self, password: str) -> None:
"""Hash and set password."""
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
"""Verify password against hash."""
return check_password_hash(self.password_hash, password)
@classmethod
def get_by_username(cls, db, username: str):
"""Get admin by username.
Args:
db: Database session
username: Admin username
Returns:
AdminUser instance or None
"""
return (
db.query(cls)
.filter(cls.username == username, cls.is_active == True) # noqa: E712
.first()
)
@classmethod
def authenticate(cls, db, username: str, password: str):
"""Authenticate admin with username and password.
Args:
db: Database session
username: Admin username
password: Plain text password
Returns:
AdminUser instance if valid, None otherwise
"""
admin = cls.get_by_username(db, username)
if admin and admin.check_password(password):
return admin
return None

262
customer_portal/models/booking.py Executable file
View File

@@ -0,0 +1,262 @@
"""Booking model.
Sprint 14: Import bookings from WordPress kurs-booking plugin.
"""
import contextlib
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Column,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from customer_portal.models import Base
class Booking(Base):
"""Booking synchronized from WordPress kurs-booking.
Each booking is linked to a customer via email address matching.
The wp_booking_id is the unique identifier from WordPress.
"""
__tablename__ = "bookings"
id = Column(Integer, primary_key=True)
customer_id = Column(
Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False
)
# WordPress identifiers
wp_booking_id = Column(Integer, unique=True, nullable=False, index=True)
wp_kurs_id = Column(Integer, index=True)
# Booking data
booking_number = Column(String(50), index=True)
kurs_title = Column(String(255))
kurs_date = Column(Date)
kurs_time = Column(String(10))
kurs_end_time = Column(String(10))
kurs_location = Column(String(255))
# Status & pricing
status = Column(String(50), default="pending", index=True)
total_price = Column(Numeric(10, 2))
ticket_type = Column(String(100))
ticket_count = Column(Integer, default=1)
# Customer data snapshot (at time of booking)
customer_name = Column(String(255))
customer_email = Column(String(255))
customer_phone = Column(String(100))
# sevDesk integration
sevdesk_invoice_id = Column(Integer)
sevdesk_invoice_number = Column(String(50))
# Custom fields from booking (JSON)
custom_fields = Column(JSONB, default=dict)
extra_fields = Column(JSONB, default=dict)
# Timestamps
wp_created_at = Column(DateTime) # Original WordPress creation date
synced_at = Column(DateTime) # Last sync timestamp
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationship
customer = relationship("Customer", back_populates="bookings")
# Indexes
__table_args__ = (
Index("ix_bookings_customer_status", customer_id, status),
Index("ix_bookings_kurs_date", kurs_date),
)
def __repr__(self) -> str:
return f"<Booking {self.booking_number} ({self.status})>"
@classmethod
def get_by_wp_id(cls, db, wp_booking_id: int):
"""Get booking by WordPress ID.
Args:
db: Database session
wp_booking_id: WordPress booking post ID
Returns:
Booking instance or None
"""
return db.query(cls).filter(cls.wp_booking_id == wp_booking_id).first()
@classmethod
def get_by_customer(cls, db, customer_id: int, status: str | None = None):
"""Get all bookings for a customer.
Args:
db: Database session
customer_id: Customer ID
status: Optional status filter
Returns:
List of Booking instances
"""
query = db.query(cls).filter(cls.customer_id == customer_id)
if status:
query = query.filter(cls.status == status)
return query.order_by(cls.kurs_date.desc()).all()
@classmethod
def create_or_update_from_wp(
cls, db, customer_id: int, wp_data: dict
) -> tuple["Booking", bool]:
"""Create or update booking from WordPress API data.
Args:
db: Database session
customer_id: Customer ID to link booking to
wp_data: Data from WordPress REST API
Returns:
Tuple of (Booking instance, is_new)
"""
wp_booking_id = wp_data.get("id")
if not wp_booking_id:
raise ValueError("WordPress booking ID required")
existing = cls.get_by_wp_id(db, wp_booking_id)
is_new = existing is None
if is_new:
booking = cls(
customer_id=customer_id,
wp_booking_id=wp_booking_id,
created_at=datetime.now(UTC),
)
db.add(booking)
else:
booking = existing
# Update fields from WordPress data
booking.wp_kurs_id = wp_data.get("kurs_id")
booking.booking_number = wp_data.get("number", "")
booking.kurs_title = wp_data.get("kurs_title", "")
booking.kurs_location = wp_data.get("kurs_location", "")
booking.status = wp_data.get("status", "pending")
booking.ticket_type = wp_data.get("ticket_type", "")
booking.ticket_count = wp_data.get("ticket_count", 1)
# Parse date
kurs_date = wp_data.get("kurs_date")
if kurs_date:
with contextlib.suppress(ValueError):
booking.kurs_date = datetime.strptime(kurs_date, "%Y-%m-%d").date()
# Parse times
booking.kurs_time = wp_data.get("kurs_time", "")
booking.kurs_end_time = wp_data.get("kurs_end_time", "")
# Parse price
price = wp_data.get("price")
if price is not None:
booking.total_price = Decimal(str(price))
# Customer snapshot
customer_data = wp_data.get("customer", {})
if customer_data:
booking.customer_name = customer_data.get("name", "")
booking.customer_email = customer_data.get("email", "")
booking.customer_phone = customer_data.get("phone", "")
# sevDesk
sevdesk = wp_data.get("sevdesk", {})
if sevdesk:
if sevdesk.get("invoice_id"):
booking.sevdesk_invoice_id = int(sevdesk["invoice_id"])
booking.sevdesk_invoice_number = sevdesk.get("invoice_number", "")
# Custom fields
if wp_data.get("custom_fields"):
booking.custom_fields = wp_data["custom_fields"]
if wp_data.get("extra_fields"):
booking.extra_fields = wp_data["extra_fields"]
# Parse WordPress created date
wp_created = wp_data.get("created_at")
if wp_created:
with contextlib.suppress(ValueError):
booking.wp_created_at = datetime.strptime(
wp_created, "%Y-%m-%d %H:%M:%S"
)
# Update sync timestamp
booking.synced_at = datetime.now(UTC)
booking.updated_at = datetime.now(UTC)
return booking, is_new
@property
def status_display(self) -> str:
"""Get human-readable status."""
status_map = {
"pending": "Ausstehend",
"confirmed": "Bestaetigt",
"cancelled": "Storniert",
"cancel_requested": "Stornierung angefragt",
}
return status_map.get(self.status, self.status or "Unbekannt")
@property
def status_color(self) -> str:
"""Get Bootstrap color class for status."""
color_map = {
"pending": "warning",
"confirmed": "success",
"cancelled": "danger",
"cancel_requested": "info",
}
return color_map.get(self.status, "secondary")
@property
def formatted_price(self) -> str:
"""Get formatted price string."""
if self.total_price is None:
return "-"
return f"{self.total_price:.2f} EUR"
@property
def formatted_date(self) -> str:
"""Get formatted date string."""
if not self.kurs_date:
return "-"
return self.kurs_date.strftime("%d.%m.%Y")
@property
def formatted_time(self) -> str:
"""Get formatted time range."""
if not self.kurs_time:
return "-"
if self.kurs_end_time:
return f"{self.kurs_time} - {self.kurs_end_time}"
return self.kurs_time
def get_custom_field(self, key: str, default: Any = None) -> Any:
"""Get a custom field value."""
if not self.custom_fields:
return default
return self.custom_fields.get(key, default)

View File

@@ -0,0 +1,318 @@
"""Customer model.
Sprint 6.6: Added custom_fields JSON column for dynamic WordPress field sync.
Sprint 12: Consolidated all customer data into custom_fields JSON.
Added display properties for flexible field access.
"""
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from customer_portal.models import Base
class Customer(Base):
"""Customer account.
Sprint 12 Architecture:
- email: Only fixed identifier column
- custom_fields: ALL customer data (name, phone, address, etc.)
The custom_fields column stores all dynamic fields from WordPress
as JSON, enabling automatic synchronization without schema changes.
Use display properties (display_name, display_phone, display_address)
for accessing customer data with fallback logic for various field names.
"""
__tablename__ = "customers"
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True)
# Sprint 12: ALL customer data in JSONB (name, phone, address, etc.)
# Enables: searching, indexing, native JSON operators
# Example: {"name": "Max", "telefon": "+43...", "ort": "Wien"}
custom_fields = Column(JSONB, default=dict, nullable=False)
wp_user_id = Column(Integer, index=True)
# Email preferences (defaults loaded from PortalSettings)
email_notifications = Column(Boolean, default=None) # Buchungsbestaetigungen
email_reminders = Column(Boolean, default=None) # Kurserinnerungen
email_invoices = Column(Boolean, default=None) # Rechnungen
email_marketing = Column(Boolean, default=None) # Marketing/Newsletter (Opt-in)
# Admin role for portal configuration
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
last_login_at = Column(DateTime)
# Relationships
otp_codes = relationship("OTPCode", back_populates="customer")
sessions = relationship("Session", back_populates="customer")
bookings = relationship(
"Booking", back_populates="customer", cascade="all, delete-orphan"
)
# GIN index for JSONB custom_fields - enables fast searching
__table_args__ = (
Index("ix_customers_custom_fields", custom_fields, postgresql_using="gin"),
)
def __repr__(self) -> str:
return f"<Customer {self.email}>"
@classmethod
def get_by_email(cls, db, email: str):
"""Get customer by email address.
Args:
db: Database session
email: Customer email address
Returns:
Customer instance or None
"""
return db.query(cls).filter(cls.email == email.lower().strip()).first()
@classmethod
def create_with_defaults(
cls,
db,
email: str,
name: str = "",
phone: str = "",
custom_fields: dict | None = None,
**kwargs,
):
"""Create a new customer with email preference defaults from settings.
Sprint 12: All customer data goes into custom_fields.
Args:
db: Database session
email: Customer email address
name: Customer name (stored in custom_fields)
phone: Phone number (stored in custom_fields)
custom_fields: Additional custom fields
**kwargs: Additional customer attributes
Returns:
Customer instance (not committed)
"""
from customer_portal.models.settings import PortalSettings
# Get defaults from settings
defaults = PortalSettings.get_customer_defaults(db)
# Build custom_fields with name and phone
fields = custom_fields.copy() if custom_fields else {}
if name:
fields["name"] = name
if phone:
fields["phone"] = phone
customer = cls(
email=email.lower().strip(),
custom_fields=fields,
email_notifications=defaults.get("email_notifications", True),
email_reminders=defaults.get("email_reminders", True),
email_invoices=defaults.get("email_invoices", True),
email_marketing=defaults.get("email_marketing", False),
**kwargs,
)
return customer
def get_custom_fields(self) -> dict[str, Any]:
"""Get custom fields as dictionary.
With PostgreSQL JSONB, the column returns a dict directly.
Returns:
Dictionary of custom field values
"""
if not self.custom_fields:
return {}
# JSONB returns dict directly, but handle legacy TEXT gracefully
if isinstance(self.custom_fields, str):
import json
try:
return json.loads(self.custom_fields)
except (json.JSONDecodeError, TypeError):
return {}
return dict(self.custom_fields)
def set_custom_fields(self, fields: dict[str, Any]) -> None:
"""Set custom fields from dictionary.
With PostgreSQL JSONB, we can assign dict directly.
Args:
fields: Dictionary of custom field values
"""
self.custom_fields = fields
def update_custom_field(self, key: str, value: Any) -> None:
"""Update a single custom field.
Args:
key: Field name
value: Field value
"""
fields = self.get_custom_fields()
fields[key] = value
self.set_custom_fields(fields)
def get_custom_field(self, key: str, default: Any = None) -> Any:
"""Get a single custom field value.
Args:
key: Field name
default: Default value if not found
Returns:
Field value or default
"""
return self.get_custom_fields().get(key, default)
def get_all_prefill_data(self) -> dict[str, Any]:
"""Get all data for form pre-filling.
Sprint 12: All data now comes from custom_fields.
Returns:
Dictionary with all customer data for prefill
"""
data = {"email": self.email or ""}
# All other fields come from custom_fields
data.update(self.get_custom_fields())
return data
# =========================================================================
# Sprint 12: Display Properties for flexible field access
# =========================================================================
@property
def display_name(self) -> str:
"""Get display name from various possible field combinations.
Tries in order:
1. name (single field)
2. vorname + nachname (German split fields)
3. first_name + last_name (English split fields)
4. Email prefix as fallback
Returns:
Best available name string
"""
fields = self.get_custom_fields()
# Priority 1: Single name field
if fields.get("name"):
return fields["name"]
# Priority 2: German split fields
vorname = fields.get("vorname", "")
nachname = fields.get("nachname", "")
if vorname or nachname:
return f"{vorname} {nachname}".strip()
# Priority 3: English split fields
first = fields.get("first_name", "")
last = fields.get("last_name", "")
if first or last:
return f"{first} {last}".strip()
# Fallback: Email prefix
return self.email.split("@")[0] if self.email else ""
@property
def display_phone(self) -> str:
"""Get phone from various possible field names.
Tries: phone, telefon, mobil, mobile, tel
Returns:
Phone number or empty string
"""
return self.get_field("phone", "telefon", "mobil", "mobile", "tel", default="")
@property
def display_address(self) -> dict[str, str]:
"""Get address components from various field names.
Returns:
Dictionary with street, zip, city keys
"""
fields = self.get_custom_fields()
return {
"street": (
fields.get("address_street")
or fields.get("adresse")
or fields.get("strasse")
or fields.get("straße")
or fields.get("street")
or ""
),
"zip": (
fields.get("address_zip")
or fields.get("plz")
or fields.get("postleitzahl")
or fields.get("zip")
or ""
),
"city": (
fields.get("address_city")
or fields.get("ort")
or fields.get("stadt")
or fields.get("city")
or ""
),
}
@property
def display_address_oneline(self) -> str:
"""Get formatted one-line address.
Returns:
Address like 'Musterstr. 1, 12345 Berlin' or empty string
"""
addr = self.display_address
parts = []
if addr["street"]:
parts.append(addr["street"])
if addr["zip"] or addr["city"]:
parts.append(f"{addr['zip']} {addr['city']}".strip())
return ", ".join(parts)
def get_field(self, *keys: str, default: str = "") -> str:
"""Get first matching field from multiple possible keys.
Useful for fields with different naming conventions:
customer.get_field("phone", "telefon", "mobil")
Args:
*keys: Field names to try (in order of priority)
default: Default value if none found
Returns:
First found value or default
"""
fields = self.get_custom_fields()
for key in keys:
value = fields.get(key)
if value:
return value
return default

36
customer_portal/models/otp.py Executable file
View File

@@ -0,0 +1,36 @@
"""OTP model."""
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from customer_portal.models import Base
class OTPCode(Base):
"""One-time password code."""
__tablename__ = "otp_codes"
id = Column(Integer, primary_key=True)
customer_id = Column(
Integer, ForeignKey("customers.id"), nullable=False, index=True
)
code = Column(String(6), nullable=False)
purpose = Column(String(20), nullable=False) # login, register, reset
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
# Relationship
customer = relationship("Customer", back_populates="otp_codes")
def __repr__(self) -> str:
return f"<OTPCode {self.id} for customer {self.customer_id}>"
@property
def is_valid(self) -> bool:
"""Check if OTP is still valid."""
now = datetime.now(UTC)
return self.used_at is None and self.expires_at > now

View File

@@ -0,0 +1,36 @@
"""Session model."""
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from customer_portal.models import Base
class Session(Base):
"""Login session."""
__tablename__ = "sessions"
id = Column(Integer, primary_key=True)
customer_id = Column(
Integer, ForeignKey("customers.id"), nullable=False, index=True
)
token = Column(String(255), unique=True, nullable=False, index=True)
ip_address = Column(String(45))
user_agent = Column(Text)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
# Relationship
customer = relationship("Customer", back_populates="sessions")
def __repr__(self) -> str:
return f"<Session {self.id} for customer {self.customer_id}>"
@property
def is_valid(self) -> bool:
"""Check if session is still valid."""
now = datetime.now(UTC)
return self.expires_at > now

View File

@@ -0,0 +1,490 @@
"""Portal Settings model.
Stores configuration for the customer portal, including which fields
are visible and editable for customers.
"""
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import Column, DateTime, Integer, String, Text
from customer_portal.models import Base
# Default field configuration
DEFAULT_FIELD_CONFIG = {
"profile_fields": {
"name": {"visible": True, "editable": False, "label": "Name"},
"email": {"visible": True, "editable": False, "label": "E-Mail"},
"phone": {"visible": True, "editable": True, "label": "Telefon"},
"address_street": {"visible": True, "editable": True, "label": "Strasse"},
"address_zip": {"visible": True, "editable": True, "label": "PLZ"},
"address_city": {"visible": True, "editable": True, "label": "Ort"},
},
"sections": {
"contact": {"visible": True, "label": "Kontaktdaten"},
"address": {"visible": True, "label": "Adresse"},
"email_settings": {"visible": True, "label": "E-Mail Einstellungen"},
"account_info": {"visible": True, "label": "Konto"},
},
"wp_fields": {}, # WordPress booking fields (dynamically loaded from WP API)
"custom_fields_visible": False,
"sync_button_visible": False,
}
# Default mail configuration
DEFAULT_MAIL_CONFIG = {
"mail_server": "",
"mail_port": 587,
"mail_use_tls": True,
"mail_use_ssl": False,
"mail_username": "",
"mail_password": "", # Stored encrypted or empty
"mail_default_sender": "",
"mail_default_sender_name": "Kundenportal",
}
# Default OTP configuration
DEFAULT_OTP_CONFIG = {
"otp_expiry_minutes": 10,
"otp_length": 6,
"otp_max_attempts": 3,
"prefill_token_expiry": 300, # 5 minutes
}
# Default WordPress configuration
DEFAULT_WORDPRESS_CONFIG = {
"wp_api_url": "",
"wp_api_secret": "",
"wp_booking_page_url": "",
}
# Default customer preferences for new customers
DEFAULT_CUSTOMER_DEFAULTS = {
"email_notifications": True, # Buchungsbestaetigungen
"email_reminders": True, # Kurserinnerungen
"email_invoices": True, # Rechnungen
"email_marketing": False, # Marketing/Newsletter (Opt-in)
}
# Default field mapping (Portal → WordPress)
# Format: portal_field: wp_field
# When syncing, data flows: WordPress booking → Portal field
DEFAULT_FIELD_MAPPING = {
"mappings": {
# Portal field → WordPress field name
"db.phone": "telefon",
"db.address_street": "adresse",
"db.address_zip": "", # Not mapped by default
"db.address_city": "", # Not mapped by default
"custom.vorname": "vorname",
"custom.nachname": "nachname",
"custom.geburtsdatum": "geburtsdatum",
"custom.pferdename": "name_des_pferdes",
"custom.geschlecht_pferd": "geschlecht_des_pferdes",
},
"auto_sync_on_booking": True, # Sync when new booking arrives
}
# Default admin customer view configuration
DEFAULT_ADMIN_CUSTOMER_VIEW = {
"field_labels": {
# Standard-Labels fuer bekannte Felder
"address_zip": "PLZ",
"address_city": "Ort",
"address_street": "Strasse",
"vorname": "Vorname",
"nachname": "Nachname",
"geburtsdatum": "Geburtsdatum",
"pferdename": "Pferdename",
"geschlecht_pferd": "Geschlecht Pferd",
},
"hidden_fields": [], # Diese Felder nie anzeigen
"field_order": [], # Reihenfolge (leer = alphabetisch)
"contact_fields": ["name", "email", "phone", "address"], # In Kontaktdaten anzeigen
"sections": {
"contact": {"visible": True, "label": "Kontaktdaten"},
"personal": {"visible": True, "label": "Personendaten"},
"email_settings": {"visible": True, "label": "E-Mail-Einstellungen"},
"account_info": {"visible": True, "label": "Konto-Informationen"},
},
}
# Default branding configuration
DEFAULT_BRANDING_CONFIG = {
"company_name": "Kundenportal",
"logo_url": "",
"favicon_url": "",
"colors": {
"primary": "#198754", # Buttons, Links (Bootstrap success green)
"primary_hover": "#157347", # Hover states
"background": "#1a1d21", # Page background
"header_bg": "#212529", # Topbar/Sidebar background
"sidebar_bg": "#1a1d21", # Sidebar background
"text": "#f8f9fa", # Main text color
"muted": "#6c757d", # Muted text
"border": "#2d3238", # Border color
},
}
# Default CSV export/import configuration
DEFAULT_CSV_CONFIG = {
"export_fields": [
{"key": "id", "label": "ID", "enabled": True},
{"key": "email", "label": "E-Mail", "enabled": True},
{"key": "name", "label": "Name", "enabled": True},
{"key": "phone", "label": "Telefon", "enabled": True},
{"key": "address_street", "label": "Strasse", "enabled": True},
{"key": "address_zip", "label": "PLZ", "enabled": True},
{"key": "address_city", "label": "Ort", "enabled": True},
{
"key": "email_notifications",
"label": "E-Mail-Benachrichtigungen",
"enabled": True,
},
{"key": "email_reminders", "label": "E-Mail-Erinnerungen", "enabled": True},
{"key": "email_invoices", "label": "E-Mail-Rechnungen", "enabled": True},
{"key": "email_marketing", "label": "E-Mail-Marketing", "enabled": True},
{"key": "created_at", "label": "Erstellt am", "enabled": False},
{"key": "updated_at", "label": "Aktualisiert am", "enabled": False},
],
"include_custom_fields": False, # Include custom fields (Personendaten) as JSON
"delimiter": ";", # CSV delimiter
}
class PortalSettings(Base):
"""Portal configuration settings.
Stores all portal settings as JSON for flexibility.
Uses a key-value approach for different setting groups.
"""
__tablename__ = "portal_settings"
id = Column(Integer, primary_key=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False, default="{}")
updated_at = Column(
DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
def __repr__(self) -> str:
return f"<PortalSettings {self.key}>"
def get_value(self) -> Any:
"""Get setting value as Python object."""
try:
return json.loads(self.value)
except (json.JSONDecodeError, TypeError):
return {}
def set_value(self, data: Any) -> None:
"""Set setting value from Python object."""
self.value = json.dumps(data, ensure_ascii=False)
@classmethod
def get_setting(cls, db, key: str, default: Any = None) -> Any:
"""Get a setting by key.
Args:
db: Database session
key: Setting key
default: Default value if not found
Returns:
Setting value or default
"""
setting = db.query(cls).filter(cls.key == key).first()
if setting:
return setting.get_value()
return default
@classmethod
def set_setting(cls, db, key: str, value: Any) -> "PortalSettings":
"""Set a setting by key.
Args:
db: Database session
key: Setting key
value: Setting value
Returns:
PortalSettings instance
"""
setting = db.query(cls).filter(cls.key == key).first()
if not setting:
setting = cls(key=key)
db.add(setting)
setting.set_value(value)
db.commit()
return setting
@classmethod
def get_field_config(cls, db) -> dict:
"""Get the field configuration.
Returns merged default config with saved config.
Args:
db: Database session
Returns:
Field configuration dictionary
"""
saved = cls.get_setting(db, "field_config", {})
# Merge with defaults
config = DEFAULT_FIELD_CONFIG.copy()
if saved:
# Deep merge for profile_fields
if "profile_fields" in saved:
for field, settings in saved["profile_fields"].items():
if field in config["profile_fields"]:
config["profile_fields"][field].update(settings)
else:
config["profile_fields"][field] = settings
# Deep merge for sections
if "sections" in saved:
for section, settings in saved["sections"].items():
if section in config["sections"]:
config["sections"][section].update(settings)
else:
config["sections"][section] = settings
# WordPress fields (complete override, not merge)
if "wp_fields" in saved:
config["wp_fields"] = saved["wp_fields"]
# Simple overrides
if "custom_fields_visible" in saved:
config["custom_fields_visible"] = saved["custom_fields_visible"]
if "sync_button_visible" in saved:
config["sync_button_visible"] = saved["sync_button_visible"]
return config
@classmethod
def save_field_config(cls, db, config: dict) -> None:
"""Save the field configuration.
Args:
db: Database session
config: Field configuration dictionary
"""
cls.set_setting(db, "field_config", config)
# Mail configuration methods
@classmethod
def get_mail_config(cls, db) -> dict:
"""Get mail configuration with defaults."""
saved = cls.get_setting(db, "mail_config", {})
config = DEFAULT_MAIL_CONFIG.copy()
config.update(saved)
return config
@classmethod
def save_mail_config(cls, db, config: dict) -> None:
"""Save mail configuration."""
cls.set_setting(db, "mail_config", config)
# OTP configuration methods
@classmethod
def get_otp_config(cls, db) -> dict:
"""Get OTP configuration with defaults."""
saved = cls.get_setting(db, "otp_config", {})
config = DEFAULT_OTP_CONFIG.copy()
config.update(saved)
return config
@classmethod
def save_otp_config(cls, db, config: dict) -> None:
"""Save OTP configuration."""
cls.set_setting(db, "otp_config", config)
# WordPress configuration methods
@classmethod
def get_wordpress_config(cls, db) -> dict:
"""Get WordPress configuration with defaults."""
saved = cls.get_setting(db, "wordpress_config", {})
config = DEFAULT_WORDPRESS_CONFIG.copy()
config.update(saved)
return config
@classmethod
def save_wordpress_config(cls, db, config: dict) -> None:
"""Save WordPress configuration."""
cls.set_setting(db, "wordpress_config", config)
# CSV configuration methods
@classmethod
def get_csv_config(cls, db) -> dict:
"""Get CSV export/import configuration with defaults."""
saved = cls.get_setting(db, "csv_config", {})
config = {
"export_fields": DEFAULT_CSV_CONFIG["export_fields"].copy(),
"include_custom_fields": DEFAULT_CSV_CONFIG["include_custom_fields"],
"delimiter": DEFAULT_CSV_CONFIG["delimiter"],
}
if saved:
# Merge export_fields
if "export_fields" in saved:
saved_map = {f["key"]: f for f in saved["export_fields"]}
for field in config["export_fields"]:
if field["key"] in saved_map:
field.update(saved_map[field["key"]])
if "include_custom_fields" in saved:
config["include_custom_fields"] = saved["include_custom_fields"]
if "delimiter" in saved:
config["delimiter"] = saved["delimiter"]
return config
@classmethod
def save_csv_config(cls, db, config: dict) -> None:
"""Save CSV configuration."""
cls.set_setting(db, "csv_config", config)
# Customer defaults configuration methods
@classmethod
def get_customer_defaults(cls, db) -> dict:
"""Get default preferences for new customers.
These settings are applied when a new customer is created.
Args:
db: Database session
Returns:
Customer defaults dictionary
"""
saved = cls.get_setting(db, "customer_defaults", {})
config = DEFAULT_CUSTOMER_DEFAULTS.copy()
config.update(saved)
return config
@classmethod
def save_customer_defaults(cls, db, config: dict) -> None:
"""Save default preferences for new customers.
Args:
db: Database session
config: Customer defaults dictionary
"""
cls.set_setting(db, "customer_defaults", config)
# Field mapping configuration methods
@classmethod
def get_field_mapping(cls, db) -> dict:
"""Get WordPress to Portal field mapping configuration.
Args:
db: Database session
Returns:
Field mapping dictionary with 'mappings' and 'auto_sync_on_booking'
"""
saved = cls.get_setting(db, "field_mapping", {})
config = {
"mappings": DEFAULT_FIELD_MAPPING["mappings"].copy(),
"auto_sync_on_booking": DEFAULT_FIELD_MAPPING["auto_sync_on_booking"],
}
if saved:
if "mappings" in saved:
config["mappings"].update(saved["mappings"])
if "auto_sync_on_booking" in saved:
config["auto_sync_on_booking"] = saved["auto_sync_on_booking"]
return config
@classmethod
def save_field_mapping(cls, db, config: dict) -> None:
"""Save field mapping configuration.
Args:
db: Database session
config: Field mapping dictionary
"""
cls.set_setting(db, "field_mapping", config)
# Branding configuration methods
@classmethod
def get_branding(cls, db) -> dict:
"""Get branding configuration with defaults.
Args:
db: Database session
Returns:
Branding configuration dictionary
"""
saved = cls.get_setting(db, "branding", {})
config = {
"company_name": DEFAULT_BRANDING_CONFIG["company_name"],
"logo_url": DEFAULT_BRANDING_CONFIG["logo_url"],
"favicon_url": DEFAULT_BRANDING_CONFIG["favicon_url"],
"colors": DEFAULT_BRANDING_CONFIG["colors"].copy(),
}
if saved:
if "company_name" in saved:
config["company_name"] = saved["company_name"]
if "logo_url" in saved:
config["logo_url"] = saved["logo_url"]
if "favicon_url" in saved:
config["favicon_url"] = saved["favicon_url"]
if "colors" in saved:
config["colors"].update(saved["colors"])
return config
@classmethod
def save_branding(cls, db, config: dict) -> None:
"""Save branding configuration.
Args:
db: Database session
config: Branding configuration dictionary
"""
cls.set_setting(db, "branding", config)
# Admin Customer View configuration methods
@classmethod
def get_admin_customer_view(cls, db) -> dict:
"""Get admin customer view configuration with defaults.
Args:
db: Database session
Returns:
Admin customer view configuration dictionary
"""
saved = cls.get_setting(db, "admin_customer_view", {})
config = {
"field_labels": DEFAULT_ADMIN_CUSTOMER_VIEW["field_labels"].copy(),
"hidden_fields": DEFAULT_ADMIN_CUSTOMER_VIEW["hidden_fields"].copy(),
"field_order": DEFAULT_ADMIN_CUSTOMER_VIEW["field_order"].copy(),
"contact_fields": DEFAULT_ADMIN_CUSTOMER_VIEW["contact_fields"].copy(),
"sections": {
k: v.copy() for k, v in DEFAULT_ADMIN_CUSTOMER_VIEW["sections"].items()
},
}
if saved:
if "field_labels" in saved:
config["field_labels"].update(saved["field_labels"])
if "hidden_fields" in saved:
config["hidden_fields"] = saved["hidden_fields"]
if "field_order" in saved:
config["field_order"] = saved["field_order"]
if "contact_fields" in saved:
config["contact_fields"] = saved["contact_fields"]
if "sections" in saved:
for section_key, section_data in saved["sections"].items():
if section_key in config["sections"]:
config["sections"][section_key].update(section_data)
return config
@classmethod
def save_admin_customer_view(cls, db, config: dict) -> None:
"""Save admin customer view configuration.
Args:
db: Database session
config: Admin customer view configuration dictionary
"""
cls.set_setting(db, "admin_customer_view", config)

View File

@@ -0,0 +1 @@
"""Portal management scripts."""

Binary file not shown.

View File

@@ -0,0 +1,65 @@
"""Create initial admin user.
Run with:
docker exec customer_portal python -m customer_portal.scripts.create_admin
"""
import os
from sqlalchemy import create_engine, text
from werkzeug.security import generate_password_hash
def create_admin():
"""Create initial admin user if none exists."""
database_url = os.environ.get(
"DATABASE_URL", "postgresql://portal:portal@localhost:5432/customer_portal"
)
engine = create_engine(database_url)
with engine.connect() as conn:
# Check if any admin exists
result = conn.execute(text("SELECT COUNT(*) FROM admin_users"))
count = result.fetchone()[0]
if count > 0:
print(f"Es existieren bereits {count} Admin(s).")
result = conn.execute(text("SELECT username, name FROM admin_users"))
for row in result:
print(f" - {row[0]} ({row[1]})")
return
# Create default admin
username = "admin"
password = "admin123!"
password_hash = generate_password_hash(password)
conn.execute(
text(
"""
INSERT INTO admin_users (username, password_hash, name, email, is_active)
VALUES (:username, :password_hash, :name, :email, TRUE)
"""
),
{
"username": username,
"password_hash": password_hash,
"name": "Administrator",
"email": "admin@example.com",
},
)
conn.commit()
print("=" * 50)
print("Admin-Benutzer erfolgreich erstellt!")
print("=" * 50)
print(f" Benutzername: {username}")
print(f" Passwort: {password}")
print("")
print("WICHTIG: Aendern Sie das Passwort nach dem ersten Login!")
print("=" * 50)
if __name__ == "__main__":
create_admin()

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

View File

@@ -0,0 +1 @@
"""Utility functions."""

View File

@@ -0,0 +1 @@
"""Web application package."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
customer_portal/web/app.py Executable file
View File

@@ -0,0 +1,93 @@
"""Flask application factory."""
import os
from datetime import UTC, datetime
from flask import Flask, g
from flask_cors import CORS
def create_app(config_name: str | None = None) -> Flask:
"""Create Flask application."""
from customer_portal.config import config
app = Flask(__name__)
# Load config
config_name = config_name or os.getenv("FLASK_ENV", "default")
app.config.from_object(config[config_name])
# Extensions
CORS(app)
# Initialize Flask-Mail
from customer_portal.services.email import mail
mail.init_app(app)
# Initialize database
from customer_portal.models import close_db, create_tables, init_db
init_db(app.config["SQLALCHEMY_DATABASE_URI"])
# Create tables on first request (development)
with app.app_context():
create_tables()
# Teardown - close db session
app.teardown_appcontext(close_db)
# Register Jinja2 filters
from customer_portal.web.filters import register_filters
register_filters(app)
# Context processor for templates
@app.context_processor
def inject_globals():
from customer_portal.models import get_db
from customer_portal.models.settings import (
DEFAULT_BRANDING_CONFIG,
PortalSettings,
)
# Get branding config (with fallback to defaults)
branding = DEFAULT_BRANDING_CONFIG.copy()
try:
db = get_db()
branding = PortalSettings.get_branding(db)
except Exception:
pass # Use defaults if DB not available
return {
"now": lambda: datetime.now(UTC),
"branding": branding,
}
# Make g.customer available in templates
@app.before_request
def load_customer():
g.customer = None
# Blueprints
from customer_portal.web.routes import (
admin,
api,
auth,
bookings,
invoices,
main,
profile,
videos,
)
app.register_blueprint(main.bp)
app.register_blueprint(auth.bp, url_prefix="/auth")
app.register_blueprint(api.bp, url_prefix="/api")
app.register_blueprint(admin.bp, url_prefix="/admin")
app.register_blueprint(bookings.bp, url_prefix="/bookings")
app.register_blueprint(invoices.bp, url_prefix="/invoices")
app.register_blueprint(profile.bp, url_prefix="/profile")
app.register_blueprint(videos.bp, url_prefix="/videos")
return app

154
customer_portal/web/filters.py Executable file
View File

@@ -0,0 +1,154 @@
"""Jinja2 template filters."""
from datetime import datetime
from typing import Any
def register_filters(app):
"""Register custom Jinja2 filters."""
@app.template_filter("date")
def format_date(value: Any, fmt: str = "%d.%m.%Y") -> str:
"""Format date string or datetime object.
Args:
value: Date string (ISO format) or datetime object
fmt: Output format (default: German date format)
Returns:
Formatted date string or empty string if invalid
"""
if not value:
return ""
try:
if isinstance(value, str):
# Handle ISO format dates
if "T" in value:
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
# Try common formats
for parse_fmt in ["%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%d %H:%M:%S"]:
try:
value = datetime.strptime(value, parse_fmt)
break
except ValueError:
continue
else:
return str(value)
if isinstance(value, datetime):
return value.strftime(fmt)
return str(value)
except (ValueError, TypeError):
return str(value) if value else ""
@app.template_filter("datetime")
def format_datetime(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str:
"""Format datetime with time.
Args:
value: Date string or datetime object
fmt: Output format (default: German datetime format)
Returns:
Formatted datetime string
"""
return format_date(value, fmt)
@app.template_filter("format_price")
def format_price(value: Any) -> str:
"""Format price with German number format.
Args:
value: Numeric value or string
Returns:
Formatted price string (e.g., "1.234,56")
"""
try:
num = float(value)
# German format: 1.234,56
formatted = f"{num:,.2f}"
# Swap . and , for German format
formatted = formatted.replace(",", "X").replace(".", ",").replace("X", ".")
return formatted
except (ValueError, TypeError):
return str(value) if value else "0,00"
@app.template_filter("status_badge")
def status_badge(status: str) -> str:
"""Get Bootstrap badge class for booking status.
Args:
status: Booking status string
Returns:
Bootstrap badge class
"""
badges = {
"confirmed": "bg-success",
"pending": "bg-warning text-dark",
"cancelled": "bg-danger",
"cancel_requested": "bg-secondary",
}
return badges.get(status, "bg-secondary")
@app.template_filter("status_label")
def status_label(status: str) -> str:
"""Get German label for booking status.
Args:
status: Booking status string
Returns:
German status label
"""
labels = {
"confirmed": "Bestaetigt",
"pending": "Ausstehend",
"cancelled": "Storniert",
"cancel_requested": "Storno beantragt",
}
return labels.get(status, status or "Unbekannt")
@app.template_filter("invoice_status_badge")
def invoice_status_badge(status: str) -> str:
"""Get Bootstrap badge class for invoice status.
Args:
status: Invoice status string from sevDesk
Returns:
Bootstrap badge class
"""
badges = {
"paid": "bg-success",
"open": "bg-warning text-dark",
"overdue": "bg-danger",
"draft": "bg-secondary",
"cancelled": "bg-secondary",
"unknown": "bg-secondary",
}
return badges.get(status, "bg-secondary")
@app.template_filter("invoice_status_label")
def invoice_status_label(status: str) -> str:
"""Get German label for invoice status.
Args:
status: Invoice status string from sevDesk
Returns:
German status label
"""
labels = {
"paid": "Bezahlt",
"open": "Offen",
"overdue": "Ueberfaellig",
"draft": "Entwurf",
"cancelled": "Storniert",
"unknown": "Unbekannt",
}
return labels.get(status, status or "Unbekannt")

View File

@@ -0,0 +1 @@
"""Route blueprints."""

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.

File diff suppressed because it is too large Load Diff

502
customer_portal/web/routes/api.py Executable file
View File

@@ -0,0 +1,502 @@
"""Internal API routes for external integrations."""
import logging
from flask import Blueprint, current_app, jsonify, request
from customer_portal.models import get_db
from customer_portal.models.customer import Customer
from customer_portal.services.email import EmailService
from customer_portal.services.otp import OTPService
from customer_portal.services.token import TokenService
from customer_portal.services.wordpress_api import WordPressWebhook
bp = Blueprint("api", __name__)
logger = logging.getLogger(__name__)
def _is_consent_field(field_name: str, field_type: str) -> bool:
"""Check if field is a consent/checkbox field that should be excluded.
Consent fields contain keywords like "stimme", "akzeptiere", "einwilligung"
and are typically checkboxes.
Args:
field_name: The field name (lowercase)
field_type: The field type
Returns:
True if this is a consent field
"""
consent_keywords = [
"stimme",
"akzeptiere",
"einwilligung",
"zustimmung",
"consent",
"agree",
"accept",
"newsletter",
"marketing",
"agb",
"datenschutz",
"widerruf",
"kontaktaufnahme",
]
return field_type == "checkbox" or any(
keyword in field_name for keyword in consent_keywords
)
def _generate_field_mapping(booking_fields: dict) -> dict:
"""Generate field mapping from WordPress booking fields.
Analyzes the booking fields sent by WordPress to determine which
form field names correspond to standard customer data fields.
Sprint 6.7: Improved email field detection to exclude consent fields.
Args:
booking_fields: Dictionary of booking fields from WordPress
Format: {"fieldname": {"type": "email", "label": "E-Mail"}, ...}
Returns:
Dictionary with field mappings like:
{
"email_field": "e_mail",
"name_field": "name",
"phone_field": "telefon",
"address_field": "adresse",
"zip_field": "plz",
"city_field": "ort"
}
"""
# Default fallbacks
mapping = {
"email_field": "email",
"name_field": "name",
"phone_field": "phone",
"address_field": "address",
"zip_field": "zip",
"city_field": "city",
}
if not booking_fields or not isinstance(booking_fields, dict):
return mapping
# booking_fields structure: {"fieldname": {"type": "email", "label": "E-Mail"}, ...}
for field_name, field_info in booking_fields.items():
if not isinstance(field_info, dict):
continue
field_type = field_info.get("type", "")
name_lower = field_name.lower()
# Skip consent/checkbox fields entirely
if _is_consent_field(name_lower, field_type):
continue
# Email field detection (more specific):
# 1. type="email" is the most reliable indicator
# 2. Field name is exactly or starts with email/e_mail/e-mail/mail
# 3. Must NOT be a long field name (consent fields are usually long)
is_email_field = (
field_type == "email"
or name_lower in ("email", "e_mail", "e-mail", "mail")
or (
name_lower.startswith(("email", "e_mail", "e-mail"))
and len(name_lower) < 15
)
)
if is_email_field:
mapping["email_field"] = field_name
# Name field: name is exactly "name" or contains "name" (excluding nachname, eltern, pferd)
if field_name == "name" or (
"name" in name_lower
and field_type == "text"
and "nach" not in name_lower
and "eltern" not in name_lower
and "pferd" not in name_lower
):
mapping["name_field"] = field_name
# Phone field: type="tel" or name contains "telefon"/"phone"
if field_type == "tel" or "telefon" in name_lower or "phone" in name_lower:
# Prefer "telefon" over "mobil"
if mapping["phone_field"] == "phone" or "telefon" in name_lower:
mapping["phone_field"] = field_name
# Address field: name contains "adresse"/"address"/"strasse"
if (
"adresse" in name_lower
or "address" in name_lower
or "strasse" in name_lower
or "straße" in name_lower
):
mapping["address_field"] = field_name
# ZIP/PLZ field: name contains "plz"/"zip"/"postleitzahl"
if "plz" in name_lower or "zip" in name_lower or "postleitzahl" in name_lower:
mapping["zip_field"] = field_name
# City/Ort field: name contains "ort"/"city"/"stadt"
if "ort" in name_lower or "city" in name_lower or "stadt" in name_lower:
mapping["city_field"] = field_name
return mapping
@bp.route("/webhook/booking", methods=["POST"])
def webhook_booking():
"""Handle booking webhook from WordPress.
Expected payload:
{
"email": "customer@example.com",
"name": "Customer Name",
"phone": "optional phone"
}
Required header:
X-Portal-Secret: <WP_API_SECRET>
"""
# Verify secret
secret = request.headers.get("X-Portal-Secret")
expected_secret = current_app.config.get("WP_API_SECRET")
if not expected_secret:
logger.error("WP_API_SECRET not configured")
return jsonify({"error": "Server configuration error"}), 500
if secret != expected_secret:
logger.warning(f"Unauthorized webhook attempt from {request.remote_addr}")
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json()
if not data:
return jsonify({"error": "No JSON data provided"}), 400
db = get_db()
result = WordPressWebhook.handle_booking_created(db, data)
if "error" in result:
return jsonify(result), 400
logger.info(
f"Webhook processed successfully for customer ID: {result.get('customer_id')}"
)
return jsonify(result)
@bp.route("/health", methods=["GET"])
def health():
"""Health check endpoint."""
return jsonify({"status": "ok"})
def _verify_portal_secret() -> bool:
"""Verify X-Portal-Secret header matches configured secret."""
secret = request.headers.get("X-Portal-Secret")
expected_secret = current_app.config.get("WP_API_SECRET")
if not expected_secret:
logger.error("WP_API_SECRET not configured")
return False
return secret == expected_secret
@bp.route("/customer/exists", methods=["POST"])
def customer_exists():
"""Check if a customer with given email exists.
WordPress calls this when customer enters email in booking form
to automatically detect existing customers.
Expected payload:
{
"email": "customer@example.com"
}
Required header:
X-Portal-Secret: <WP_API_SECRET>
Returns:
{
"exists": true/false
}
"""
if not _verify_portal_secret():
logger.warning(f"Unauthorized customer check from {request.remote_addr}")
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json()
if not data or not data.get("email"):
return jsonify({"error": "Email required"}), 400
email = data["email"].lower().strip()
db = get_db()
customer = Customer.get_by_email(db, email)
# Don't reveal too much - just exists or not
return jsonify({"exists": customer is not None})
@bp.route("/prefill/request", methods=["POST"])
def prefill_request():
"""Request OTP for form pre-filling from WordPress.
WordPress calls this when customer clicks "Ich bin bereits Kunde"
and enters their email. If the customer exists, we send an OTP.
Expected payload:
{
"email": "customer@example.com"
}
Required header:
X-Portal-Secret: <WP_API_SECRET>
Returns:
{
"success": true,
"message": "OTP sent if customer exists"
}
"""
if not _verify_portal_secret():
logger.warning(f"Unauthorized prefill request from {request.remote_addr}")
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json()
if not data or not data.get("email"):
return jsonify({"error": "Email required"}), 400
email = data["email"].lower().strip()
db = get_db()
# Check if customer exists
customer = Customer.get_by_email(db, email)
if customer:
# Generate OTP and send email
try:
code = OTPService.create_for_customer(db, customer.id, purpose="prefill")
EmailService.send_otp(customer.email, code, purpose="prefill")
logger.info(f"Prefill OTP sent to {email}")
except Exception as e:
logger.error(f"Failed to send prefill OTP: {e}")
# Don't reveal if customer exists or not
pass
# Always return success to prevent email enumeration
return jsonify(
{
"success": True,
"message": "Falls Sie bei uns registriert sind, erhalten Sie einen Code per E-Mail.",
}
)
@bp.route("/prefill/verify", methods=["POST"])
def prefill_verify():
"""Verify OTP and return prefill token.
WordPress calls this after customer enters the OTP code.
Expected payload:
{
"email": "customer@example.com",
"code": "123456"
}
Required header:
X-Portal-Secret: <WP_API_SECRET>
Returns on success:
{
"success": true,
"token": "base64_encoded_token",
"customer": {
"name": "Max Mustermann",
"email": "max@example.com",
"phone": "+43..."
}
}
Returns on failure:
{
"success": false,
"error": "Invalid or expired code"
}
"""
if not _verify_portal_secret():
logger.warning(f"Unauthorized prefill verify from {request.remote_addr}")
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
email = (data.get("email") or "").lower().strip()
code = (data.get("code") or "").strip()
if not email or not code:
return jsonify({"success": False, "error": "Email and code required"}), 400
db = get_db()
# Get customer
customer = Customer.get_by_email(db, email)
if not customer:
return jsonify({"success": False, "error": "Ungueltiger Code"}), 400
# Verify OTP
if not OTPService.verify(db, customer.id, code, purpose="prefill"):
return (
jsonify({"success": False, "error": "Ungueltiger oder abgelaufener Code"}),
400,
)
# Generate prefill token
try:
token = TokenService.generate_prefill_token(customer)
except ValueError as e:
logger.error(f"Token generation failed: {e}")
return jsonify({"success": False, "error": "Server error"}), 500
logger.info(f"Prefill token generated for customer {customer.id}")
# Sprint 12: All customer data comes from custom_fields
# Use display properties for backwards compatibility
addr = customer.display_address
customer_data = {
"name": customer.display_name,
"email": customer.email,
"phone": customer.display_phone,
"address_street": addr.get("street", ""),
"address_city": addr.get("city", ""),
"address_zip": addr.get("zip", ""),
}
# Include all custom_fields for full data access
custom_fields = customer.get_custom_fields()
if custom_fields:
customer_data["custom_fields"] = custom_fields
# Generate field mapping from booking_fields sent by WordPress
booking_fields = data.get("booking_fields", {})
field_mapping = _generate_field_mapping(booking_fields)
# Debug logging
logger.info(f"Customer data: {customer_data}")
logger.info(f"Booking fields from WP: {booking_fields}")
logger.info(f"Generated field mapping: {field_mapping}")
return jsonify(
{
"success": True,
"token": token,
"customer": customer_data,
"field_mapping": field_mapping,
}
)
# Sprint 6.6: Schema endpoint for WordPress field sync
@bp.route("/schema", methods=["GET"])
def get_schema():
"""Get field schema from WordPress.
Proxies the WordPress schema endpoint for frontend use.
Enables dynamic form generation based on WordPress settings.
Query params:
kurs_id: Optional kurs ID for kurs-specific fields
Returns:
Schema dictionary from WordPress
"""
from customer_portal.services.wordpress_api import WordPressAPI
kurs_id = request.args.get("kurs_id", type=int)
try:
schema = WordPressAPI.get_schema(kurs_id)
return jsonify(schema)
except Exception as e:
logger.error(f"Error fetching schema: {e}")
return jsonify({"error": "Failed to fetch schema"}), 500
@bp.route("/customer/fields", methods=["GET", "PATCH"])
def customer_fields():
"""Get or update customer custom fields.
Requires authentication via session cookie.
GET: Returns all custom fields for current customer
PATCH: Updates custom fields for current customer
Returns:
Customer custom fields dictionary
"""
from customer_portal.web.decorators import get_current_customer
customer = get_current_customer()
if not customer:
return jsonify({"error": "Not authenticated"}), 401
db = get_db()
if request.method == "GET":
# Sprint 12: All data comes from custom_fields
# Return display properties for backwards compatibility
addr = customer.display_address
return jsonify(
{
"custom_fields": customer.get_custom_fields(),
"core_fields": {
"name": customer.display_name,
"email": customer.email,
"phone": customer.display_phone,
"address_street": addr.get("street", ""),
"address_city": addr.get("city", ""),
"address_zip": addr.get("zip", ""),
},
}
)
# PATCH: Update fields - Sprint 12: All updates go to custom_fields
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
updated = []
existing = customer.get_custom_fields()
# Update core fields (now stored in custom_fields)
core_fields = data.get("core_fields", {})
if core_fields:
for key in ("phone", "address_street", "address_city", "address_zip"):
if key in core_fields:
existing[key] = core_fields[key]
updated.append(key)
# Update custom fields
custom_fields = data.get("custom_fields", {})
if custom_fields:
existing.update(custom_fields)
updated.extend(list(custom_fields.keys()))
# Save all to custom_fields JSON
if updated:
customer.set_custom_fields(existing)
db.commit()
logger.info(f"Customer {customer.id} fields updated: {updated}")
return jsonify({"success": True, "updated": updated})

View File

@@ -0,0 +1,332 @@
"""Authentication routes."""
from datetime import UTC, datetime, timedelta
from functools import wraps
from flask import (
Blueprint,
current_app,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
)
from customer_portal.models import get_db
from customer_portal.models.customer import Customer
from customer_portal.services import AuthService, EmailService, OTPService
bp = Blueprint("auth", __name__)
def login_required(f):
"""Decorator to require authentication."""
@wraps(f)
def decorated_function(*args, **kwargs):
token = session.get("auth_token")
if not token:
return redirect(url_for("auth.login"))
db = get_db()
customer = AuthService.get_customer_by_token(db, token)
if not customer:
session.clear()
return redirect(url_for("auth.login"))
g.customer = customer
return f(*args, **kwargs)
return decorated_function
@bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page - enter email."""
# Already logged in?
if session.get("auth_token"):
db = get_db()
if AuthService.get_customer_by_token(db, session["auth_token"]):
return redirect(url_for("main.dashboard"))
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
if not email or "@" not in email:
flash("Bitte geben Sie eine gueltige E-Mail-Adresse ein.", "error")
return render_template("auth/login.html")
db = get_db()
# Get or create customer
customer = AuthService.get_or_create_customer(db, email)
# Rate limiting: max 5 OTPs per hour
max_attempts = current_app.config.get("OTP_MAX_ATTEMPTS", 5)
recent = OTPService.count_recent_attempts(db, customer.id)
if recent >= max_attempts:
flash(
"Zu viele Anfragen. Bitte warten Sie eine Stunde.",
"error",
)
return render_template("auth/login.html")
# Generate and send OTP
code = OTPService.create_for_customer(db, customer.id, "login")
EmailService.send_otp(email, code, "login")
# Store customer ID for verification step
session["pending_customer_id"] = customer.id
session["pending_email"] = email
return redirect(url_for("auth.verify"))
return render_template("auth/login.html")
@bp.route("/verify", methods=["GET", "POST"])
def verify():
"""Verify OTP code."""
customer_id = session.get("pending_customer_id")
email = session.get("pending_email")
if not customer_id:
return redirect(url_for("auth.login"))
if request.method == "POST":
code = request.form.get("code", "").strip()
if not code or len(code) != 6:
flash("Bitte geben Sie den 6-stelligen Code ein.", "error")
return render_template("auth/verify.html", email=email)
db = get_db()
if OTPService.verify(db, customer_id, code, "login"):
# Create session
token = AuthService.create_session(
db,
customer_id,
request.remote_addr,
request.user_agent.string if request.user_agent else "",
)
# Store session token
session["auth_token"] = token
session.pop("pending_customer_id", None)
session.pop("pending_email", None)
flash("Erfolgreich eingeloggt!", "success")
return redirect(url_for("main.dashboard"))
flash("Ungueltiger oder abgelaufener Code.", "error")
return render_template("auth/verify.html", email=email)
@bp.route("/resend", methods=["POST"])
def resend():
"""Resend OTP code."""
customer_id = session.get("pending_customer_id")
email = session.get("pending_email")
if not customer_id or not email:
return redirect(url_for("auth.login"))
db = get_db()
# Rate limiting
max_attempts = current_app.config.get("OTP_MAX_ATTEMPTS", 5)
recent = OTPService.count_recent_attempts(db, customer_id)
if recent >= max_attempts:
flash("Zu viele Anfragen. Bitte warten Sie eine Stunde.", "error")
return redirect(url_for("auth.verify"))
# Generate new code
code = OTPService.create_for_customer(db, customer_id, "login")
EmailService.send_otp(email, code, "login")
flash("Ein neuer Code wurde gesendet.", "success")
return redirect(url_for("auth.verify"))
@bp.route("/logout")
def logout():
"""Logout and clear session."""
token = session.get("auth_token")
if token:
db = get_db()
AuthService.logout(db, token)
session.clear()
flash("Sie wurden erfolgreich ausgeloggt.", "success")
return redirect(url_for("auth.login"))
# =============================================================================
# Registration Routes
# =============================================================================
@bp.route("/register", methods=["GET", "POST"])
def register():
"""Registration - Step 1: Enter email."""
# Already logged in?
if session.get("auth_token"):
db = get_db()
if AuthService.get_customer_by_token(db, session["auth_token"]):
return redirect(url_for("main.dashboard"))
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
if not email or "@" not in email:
flash("Bitte geben Sie eine gueltige E-Mail-Adresse ein.", "error")
return render_template("auth/register.html")
db = get_db()
# Check if customer already exists
customer = db.query(Customer).filter(Customer.email == email).first()
if customer:
flash(
"Diese E-Mail ist bereits registriert. Bitte melden Sie sich an.",
"info",
)
return redirect(url_for("auth.login"))
# Store email in session for registration flow
session["register_email"] = email
# Generate OTP
otp_minutes = current_app.config.get("OTP_LIFETIME_MINUTES", 10)
code = OTPService.generate()
session["register_otp"] = code
session["register_otp_expires"] = (
datetime.now(UTC) + timedelta(minutes=otp_minutes)
).isoformat()
# Send OTP email
EmailService.send_otp(email, code, "register")
flash("Ein Bestaetigungscode wurde an Ihre E-Mail gesendet.", "success")
return redirect(url_for("auth.register_verify"))
return render_template("auth/register.html")
@bp.route("/register/verify", methods=["GET", "POST"])
def register_verify():
"""Registration - Step 2: Verify OTP."""
email = session.get("register_email")
if not email:
return redirect(url_for("auth.register"))
if request.method == "POST":
code = request.form.get("code", "").strip()
stored_code = session.get("register_otp")
expires_str = session.get("register_otp_expires")
if not code or len(code) != 6:
flash("Bitte geben Sie den 6-stelligen Code ein.", "error")
return render_template("auth/register_verify.html", email=email)
if stored_code and code == stored_code and expires_str:
# Check if not expired
expires = datetime.fromisoformat(expires_str)
if expires > datetime.now(UTC):
session["register_verified"] = True
return redirect(url_for("auth.register_profile"))
flash("Ungueltiger oder abgelaufener Code.", "error")
return render_template("auth/register_verify.html", email=email)
@bp.route("/register/resend", methods=["POST"])
def register_resend():
"""Resend registration OTP."""
email = session.get("register_email")
if not email:
return redirect(url_for("auth.register"))
# Generate new OTP
otp_minutes = current_app.config.get("OTP_LIFETIME_MINUTES", 10)
code = OTPService.generate()
session["register_otp"] = code
session["register_otp_expires"] = (
datetime.now(UTC) + timedelta(minutes=otp_minutes)
).isoformat()
# Send OTP email
EmailService.send_otp(email, code, "register")
flash("Ein neuer Code wurde gesendet.", "success")
return redirect(url_for("auth.register_verify"))
@bp.route("/register/profile", methods=["GET", "POST"])
def register_profile():
"""Registration - Step 3: Enter profile data."""
email = session.get("register_email")
verified = session.get("register_verified")
if not email or not verified:
return redirect(url_for("auth.register"))
if request.method == "POST":
name = request.form.get("name", "").strip()
phone = request.form.get("phone", "").strip()
if not name:
flash("Bitte geben Sie Ihren Namen ein.", "error")
return render_template("auth/register_profile.html", email=email)
db = get_db()
# Double-check email doesn't exist (race condition protection)
existing = db.query(Customer).filter(Customer.email == email).first()
if existing:
session.pop("register_email", None)
session.pop("register_otp", None)
session.pop("register_otp_expires", None)
session.pop("register_verified", None)
flash("Diese E-Mail ist bereits registriert.", "info")
return redirect(url_for("auth.login"))
# Create customer with defaults from settings
customer = Customer.create_with_defaults(
db,
email=email,
name=name,
phone=phone if phone else None,
)
db.add(customer)
db.commit()
# Clear registration session data
session.pop("register_email", None)
session.pop("register_otp", None)
session.pop("register_otp_expires", None)
session.pop("register_verified", None)
# Auto-login after registration
token = AuthService.create_session(
db,
customer.id,
request.remote_addr,
request.user_agent.string if request.user_agent else "",
)
session["auth_token"] = token
# Send welcome email
EmailService.send_welcome(email, name)
flash("Registrierung erfolgreich! Willkommen im Kundenportal.", "success")
return redirect(url_for("main.dashboard"))
return render_template("auth/register_profile.html", email=email)

View File

@@ -0,0 +1,176 @@
"""Bookings routes.
Sprint 14: Added local database storage for bookings.
Bookings can be synced from WordPress and stored locally.
"""
import logging
from flask import (
Blueprint,
current_app,
flash,
g,
redirect,
render_template,
request,
url_for,
)
from customer_portal.models import get_db
from customer_portal.models.booking import Booking
from customer_portal.services.token import TokenService
from customer_portal.services.wordpress_api import WordPressAPI
from customer_portal.web.routes.auth import login_required
bp = Blueprint("bookings", __name__)
logger = logging.getLogger(__name__)
@bp.route("/")
@login_required
def list_bookings():
"""List customer bookings from local database.
Sprint 14: Now uses local database instead of WordPress API.
Bookings are synced via admin panel.
"""
db = get_db()
# Get filter
status_filter = request.args.get("status", "")
# Get bookings from local database
query = db.query(Booking).filter(Booking.customer_id == g.customer.id)
if status_filter:
query = query.filter(Booking.status == status_filter)
bookings = query.order_by(Booking.kurs_date.desc()).all()
return render_template(
"bookings/list.html",
bookings=bookings,
status_filter=status_filter,
use_local_db=True,
)
@bp.route("/<int:booking_id>")
@login_required
def detail(booking_id: int):
"""Booking detail page from local database.
Sprint 14: Now uses local database.
"""
db = get_db()
booking = (
db.query(Booking)
.filter(
Booking.id == booking_id,
Booking.customer_id == g.customer.id,
)
.first()
)
if not booking:
flash("Buchung nicht gefunden oder kein Zugriff.", "error")
return redirect(url_for("bookings.list_bookings"))
return render_template("bookings/detail.html", booking=booking, use_local_db=True)
@bp.route("/<int:booking_id>/cancel", methods=["GET", "POST"])
@login_required
def cancel(booking_id: int):
"""Request booking cancellation.
Sprint 14: Uses local database for booking lookup,
but still calls WordPress API for the actual cancellation.
"""
db = get_db()
# Get booking from local database
booking = (
db.query(Booking)
.filter(
Booking.id == booking_id,
Booking.customer_id == g.customer.id,
)
.first()
)
if not booking:
flash("Buchung nicht gefunden oder kein Zugriff.", "error")
return redirect(url_for("bookings.list_bookings"))
# Check if already cancelled or pending
if booking.status in ("cancelled", "cancel_requested"):
flash("Diese Buchung wurde bereits storniert oder ist in Bearbeitung.", "info")
return redirect(url_for("bookings.detail", booking_id=booking_id))
if request.method == "POST":
reason = request.form.get("reason", "").strip()
try:
# Call WordPress API to cancel (uses wp_booking_id)
result = WordPressAPI.cancel_booking(
booking.wp_booking_id, g.customer.email, reason
)
if result.get("success"):
# Update local status
booking.status = "cancel_requested"
db.commit()
flash(
"Stornierungsanfrage wurde gesendet. Sie erhalten eine Bestätigung per E-Mail.",
"success",
)
return redirect(url_for("bookings.detail", booking_id=booking_id))
else:
error = result.get("error", "Unbekannter Fehler")
flash(f"Stornierung fehlgeschlagen: {error}", "error")
except Exception as e:
logger.error(f"Error cancelling booking {booking_id}: {e}")
flash("Stornierung konnte nicht durchgeführt werden.", "error")
return render_template("bookings/cancel.html", booking=booking, use_local_db=True)
@bp.route("/book/<int:kurs_id>")
@login_required
def book_kurs(kurs_id: int):
"""Redirect to WordPress booking with pre-filled customer data.
Generates a signed token containing customer data and redirects
to the WordPress kurs page with the token as URL parameter.
WordPress validates the token and pre-fills the booking form.
Args:
kurs_id: WordPress post ID of the kurs to book
"""
try:
token = TokenService.generate_prefill_token(g.customer)
except ValueError as e:
logger.error(f"Token generation failed: {e}")
flash("Verbindung zu WordPress nicht konfiguriert.", "error")
return redirect(url_for("bookings.list_bookings"))
# Get WordPress base URL (remove API path).
wp_api_url = current_app.config.get("WP_API_URL", "")
wp_base_url = wp_api_url.replace("/wp-json/kurs-booking/v1", "")
if not wp_base_url:
logger.error("WP_API_URL not configured")
flash("WordPress-URL nicht konfiguriert.", "error")
return redirect(url_for("bookings.list_bookings"))
# Build URL to kurs page with prefill token.
# Using ?p=ID format for compatibility with any permalink structure.
booking_url = f"{wp_base_url}/?p={kurs_id}&kb_prefill={token}"
logger.info(f"Redirecting customer {g.customer.id} to book kurs {kurs_id}")
return redirect(booking_url)

Some files were not shown because too many files have changed in this diff Show More