Initial commit - Customer Portal for Coolify
This commit is contained in:
3
customer_portal/__init__.py
Executable file
3
customer_portal/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
||||
"""Kundenportal for Webwerkstatt."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
BIN
customer_portal/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/__pycache__/config.cpython-311.pyc
Executable file
BIN
customer_portal/__pycache__/config.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/__pycache__/config.cpython-312.pyc
Executable file
BIN
customer_portal/__pycache__/config.cpython-312.pyc
Executable file
Binary file not shown.
59
customer_portal/config.py
Executable file
59
customer_portal/config.py
Executable 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,
|
||||
}
|
||||
103
customer_portal/migrations/001_add_custom_fields.py
Executable file
103
customer_portal/migrations/001_add_custom_fields.py
Executable 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)
|
||||
101
customer_portal/migrations/002_add_admin_and_settings.py
Executable file
101
customer_portal/migrations/002_add_admin_and_settings.py
Executable 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()
|
||||
75
customer_portal/migrations/003_add_email_preferences.py
Executable file
75
customer_portal/migrations/003_add_email_preferences.py
Executable 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()
|
||||
259
customer_portal/migrations/004_consolidate_customer_fields.py
Executable file
259
customer_portal/migrations/004_consolidate_customer_fields.py
Executable 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()
|
||||
196
customer_portal/migrations/005_text_to_jsonb.py
Executable file
196
customer_portal/migrations/005_text_to_jsonb.py
Executable 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()
|
||||
208
customer_portal/migrations/006_drop_legacy_columns.py
Executable file
208
customer_portal/migrations/006_drop_legacy_columns.py
Executable 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()
|
||||
7
customer_portal/migrations/__init__.py
Executable file
7
customer_portal/migrations/__init__.py
Executable 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
|
||||
"""
|
||||
BIN
customer_portal/migrations/__pycache__/001_add_custom_fields.cpython-311.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/001_add_custom_fields.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/001_add_custom_fields.cpython-312.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/001_add_custom_fields.cpython-312.pyc
Executable file
Binary file not shown.
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/003_add_email_preferences.cpython-312.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/003_add_email_preferences.cpython-312.pyc
Executable file
Binary file not shown.
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/005_text_to_jsonb.cpython-311.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/005_text_to_jsonb.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/005_text_to_jsonb.cpython-312.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/005_text_to_jsonb.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/006_drop_legacy_columns.cpython-311.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/006_drop_legacy_columns.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/006_drop_legacy_columns.cpython-312.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/006_drop_legacy_columns.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/migrations/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/migrations/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
45
customer_portal/models/__init__.py
Executable file
45
customer_portal/models/__init__.py
Executable 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)
|
||||
BIN
customer_portal/models/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/admin_user.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/admin_user.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/admin_user.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/admin_user.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/booking.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/booking.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/customer.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/customer.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/customer.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/customer.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/otp.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/otp.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/session.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/session.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/session.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/session.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/settings.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/settings.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/settings.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/settings.cpython-312.pyc
Executable file
Binary file not shown.
72
customer_portal/models/admin_user.py
Executable file
72
customer_portal/models/admin_user.py
Executable 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
262
customer_portal/models/booking.py
Executable 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)
|
||||
318
customer_portal/models/customer.py
Executable file
318
customer_portal/models/customer.py
Executable 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
36
customer_portal/models/otp.py
Executable 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
|
||||
36
customer_portal/models/session.py
Executable file
36
customer_portal/models/session.py
Executable 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
|
||||
490
customer_portal/models/settings.py
Executable file
490
customer_portal/models/settings.py
Executable 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)
|
||||
1
customer_portal/scripts/__init__.py
Executable file
1
customer_portal/scripts/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Portal management scripts."""
|
||||
BIN
customer_portal/scripts/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/scripts/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/scripts/__pycache__/create_admin.cpython-312.pyc
Executable file
BIN
customer_portal/scripts/__pycache__/create_admin.cpython-312.pyc
Executable file
Binary file not shown.
65
customer_portal/scripts/create_admin.py
Executable file
65
customer_portal/scripts/create_admin.py
Executable 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()
|
||||
7
customer_portal/services/__init__.py
Executable file
7
customer_portal/services/__init__.py
Executable file
@@ -0,0 +1,7 @@
|
||||
"""Business logic services."""
|
||||
|
||||
from customer_portal.services.auth import AuthService
|
||||
from customer_portal.services.email import EmailService
|
||||
from customer_portal.services.otp import OTPService
|
||||
|
||||
__all__ = ["AuthService", "EmailService", "OTPService"]
|
||||
BIN
customer_portal/services/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/auth.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/auth.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/auth.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/auth.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/booking_import.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/booking_import.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/email.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/email.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/email.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/email.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/otp.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/otp.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/token.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/token.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/token.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/token.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-311.pyc
Executable file
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-312.pyc
Executable file
BIN
customer_portal/services/__pycache__/wordpress_api.cpython-312.pyc
Executable file
Binary file not shown.
128
customer_portal/services/auth.py
Executable file
128
customer_portal/services/auth.py
Executable file
@@ -0,0 +1,128 @@
|
||||
"""Authentication service."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from customer_portal.models.customer import Customer
|
||||
from customer_portal.models.session import Session
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handle authentication."""
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_customer(db_session, email: str) -> Customer:
|
||||
"""Get existing customer or create new one.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
email: Customer email
|
||||
|
||||
Returns:
|
||||
Customer instance
|
||||
"""
|
||||
customer = db_session.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
if not customer:
|
||||
customer = Customer.create_with_defaults(
|
||||
db_session,
|
||||
email=email,
|
||||
name=email.split("@")[0], # Temporary name from email
|
||||
)
|
||||
db_session.add(customer)
|
||||
db_session.commit()
|
||||
|
||||
return customer
|
||||
|
||||
@staticmethod
|
||||
def create_session(
|
||||
db_session, customer_id: int, ip_address: str, user_agent: str
|
||||
) -> str:
|
||||
"""Create login session.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
ip_address: Client IP address
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
Session token
|
||||
"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
hours = current_app.config.get("SESSION_LIFETIME_HOURS", 24)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
session = Session(
|
||||
customer_id=customer_id,
|
||||
token=token,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db_session.add(session)
|
||||
|
||||
# Update last login
|
||||
customer = db_session.query(Customer).get(customer_id)
|
||||
if customer:
|
||||
customer.last_login_at = datetime.now(UTC)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def get_customer_by_token(db_session, token: str) -> Customer | None:
|
||||
"""Get customer from session token.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
token: Session token
|
||||
|
||||
Returns:
|
||||
Customer if valid session, None otherwise
|
||||
"""
|
||||
session = (
|
||||
db_session.query(Session)
|
||||
.filter(
|
||||
Session.token == token,
|
||||
Session.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if session:
|
||||
return db_session.query(Customer).get(session.customer_id)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def logout(db_session, token: str) -> None:
|
||||
"""Delete session.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
token: Session token
|
||||
"""
|
||||
db_session.query(Session).filter(Session.token == token).delete()
|
||||
db_session.commit()
|
||||
|
||||
@staticmethod
|
||||
def cleanup_expired_sessions(db_session) -> int:
|
||||
"""Remove expired sessions.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
|
||||
Returns:
|
||||
Number of deleted sessions
|
||||
"""
|
||||
result = (
|
||||
db_session.query(Session)
|
||||
.filter(Session.expires_at < datetime.now(UTC))
|
||||
.delete()
|
||||
)
|
||||
db_session.commit()
|
||||
return result
|
||||
752
customer_portal/services/booking_import.py
Executable file
752
customer_portal/services/booking_import.py
Executable file
@@ -0,0 +1,752 @@
|
||||
"""Booking Import Service.
|
||||
|
||||
Supports import from CSV, JSON (MEC format), and Excel files.
|
||||
Features:
|
||||
- Automatic customer matching/creation by email
|
||||
- Overwrite protection (skip existing bookings by default)
|
||||
- Validation and error reporting
|
||||
- Support for MEC WordPress export format
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from customer_portal.models.booking import Booking
|
||||
from customer_portal.models.customer import Customer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookingImportService:
|
||||
"""Service for importing bookings from various file formats."""
|
||||
|
||||
# Field mappings for different formats
|
||||
FIELD_MAPPINGS = {
|
||||
# CSV/Excel column -> internal field
|
||||
"id": "wp_booking_id",
|
||||
"wp_id": "wp_booking_id",
|
||||
"wordpress_id": "wp_booking_id",
|
||||
"buchungsnummer": "booking_number",
|
||||
"booking_number": "booking_number",
|
||||
"number": "booking_number",
|
||||
"kurs_id": "wp_kurs_id",
|
||||
"kurs": "kurs_title",
|
||||
"kurs_title": "kurs_title",
|
||||
"kurs_titel": "kurs_title",
|
||||
"course": "kurs_title",
|
||||
"course_title": "kurs_title",
|
||||
"datum": "kurs_date",
|
||||
"date": "kurs_date",
|
||||
"kurs_date": "kurs_date",
|
||||
"kurs_datum": "kurs_date",
|
||||
"uhrzeit": "kurs_time",
|
||||
"time": "kurs_time",
|
||||
"kurs_time": "kurs_time",
|
||||
"end_time": "kurs_end_time",
|
||||
"kurs_end_time": "kurs_end_time",
|
||||
"ort": "kurs_location",
|
||||
"location": "kurs_location",
|
||||
"kurs_location": "kurs_location",
|
||||
"status": "status",
|
||||
"buchungsstatus": "status",
|
||||
"preis": "total_price",
|
||||
"price": "total_price",
|
||||
"total_price": "total_price",
|
||||
"gesamtpreis": "total_price",
|
||||
"ticket_typ": "ticket_type",
|
||||
"ticket_type": "ticket_type",
|
||||
"anzahl": "ticket_count",
|
||||
"count": "ticket_count",
|
||||
"ticket_count": "ticket_count",
|
||||
"name": "customer_name",
|
||||
"kunde": "customer_name",
|
||||
"customer_name": "customer_name",
|
||||
"kundenname": "customer_name",
|
||||
"email": "customer_email",
|
||||
"e-mail": "customer_email",
|
||||
"customer_email": "customer_email",
|
||||
"telefon": "customer_phone",
|
||||
"phone": "customer_phone",
|
||||
"customer_phone": "customer_phone",
|
||||
"sevdesk_invoice_id": "sevdesk_invoice_id",
|
||||
"rechnung_id": "sevdesk_invoice_id",
|
||||
"sevdesk_invoice_number": "sevdesk_invoice_number",
|
||||
"rechnungsnummer": "sevdesk_invoice_number",
|
||||
"erstellt": "wp_created_at",
|
||||
"created_at": "wp_created_at",
|
||||
"wp_created_at": "wp_created_at",
|
||||
}
|
||||
|
||||
# Status mappings (German -> internal)
|
||||
STATUS_MAPPINGS = {
|
||||
"bestaetigt": "confirmed",
|
||||
"bestätigt": "confirmed",
|
||||
"confirmed": "confirmed",
|
||||
"ausstehend": "pending",
|
||||
"pending": "pending",
|
||||
"storniert": "cancelled",
|
||||
"cancelled": "cancelled",
|
||||
"canceled": "cancelled",
|
||||
"stornierung angefragt": "cancel_requested",
|
||||
"cancel_requested": "cancel_requested",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def import_from_csv(
|
||||
cls,
|
||||
db_session,
|
||||
file_content: str | bytes,
|
||||
overwrite: bool = False,
|
||||
delimiter: str = ";",
|
||||
) -> dict[str, Any]:
|
||||
"""Import bookings from CSV file.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
file_content: CSV file content (string or bytes)
|
||||
overwrite: If True, update existing bookings. If False, skip them.
|
||||
delimiter: CSV delimiter (default: semicolon for German Excel)
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
if isinstance(file_content, bytes):
|
||||
# Try UTF-8 with BOM first, then UTF-8, then Latin-1
|
||||
for encoding in ["utf-8-sig", "utf-8", "latin-1"]:
|
||||
try:
|
||||
file_content = file_content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
reader = csv.DictReader(io.StringIO(file_content), delimiter=delimiter)
|
||||
rows = list(reader)
|
||||
|
||||
return cls._import_rows(db_session, rows, overwrite, source="CSV")
|
||||
|
||||
@classmethod
|
||||
def import_from_json(
|
||||
cls,
|
||||
db_session,
|
||||
file_content: str | bytes,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import bookings from JSON file (MEC format).
|
||||
|
||||
Supports both:
|
||||
- Array of booking objects: [{"id": 1, ...}, ...]
|
||||
- MEC export format: {"bookings": [...], "meta": {...}}
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
file_content: JSON file content
|
||||
overwrite: If True, update existing bookings
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
if isinstance(file_content, bytes):
|
||||
file_content = file_content.decode("utf-8-sig")
|
||||
|
||||
data = json.loads(file_content)
|
||||
|
||||
# Handle different JSON structures
|
||||
if isinstance(data, list):
|
||||
rows = data
|
||||
elif isinstance(data, dict):
|
||||
# MEC export format or similar
|
||||
rows = data.get("bookings", data.get("data", data.get("items", [])))
|
||||
if not rows and "id" in data:
|
||||
# Single booking object
|
||||
rows = [data]
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Ungültiges JSON-Format",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
return cls._import_rows(db_session, rows, overwrite, source="JSON")
|
||||
|
||||
@classmethod
|
||||
def import_from_excel(
|
||||
cls,
|
||||
db_session,
|
||||
file_content: bytes,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Import bookings from Excel file (.xlsx).
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
file_content: Excel file content (bytes)
|
||||
overwrite: If True, update existing bookings
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "openpyxl nicht installiert. Bitte 'pip install openpyxl' ausführen.",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
workbook = openpyxl.load_workbook(io.BytesIO(file_content), data_only=True)
|
||||
sheet = workbook.active
|
||||
|
||||
# Get headers from first row
|
||||
headers = [cell.value for cell in sheet[1] if cell.value]
|
||||
if not headers:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Keine Spaltenüberschriften gefunden",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# Convert rows to dictionaries
|
||||
rows = []
|
||||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
||||
if any(cell is not None for cell in row):
|
||||
row_dict = {}
|
||||
for i, value in enumerate(row):
|
||||
if i < len(headers) and headers[i]:
|
||||
row_dict[headers[i]] = value
|
||||
rows.append(row_dict)
|
||||
|
||||
return cls._import_rows(db_session, rows, overwrite, source="Excel")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Excel import error")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Excel-Lesefehler: {e!s}",
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _import_rows(
|
||||
cls,
|
||||
db_session,
|
||||
rows: list[dict],
|
||||
overwrite: bool,
|
||||
source: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Process and import rows.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
rows: List of row dictionaries
|
||||
overwrite: If True, update existing bookings
|
||||
source: Source format name for logging
|
||||
|
||||
Returns:
|
||||
Import result dictionary
|
||||
"""
|
||||
result = {
|
||||
"success": True,
|
||||
"source": source,
|
||||
"total_rows": len(rows),
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"skipped_existing": 0,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
if not rows:
|
||||
result["warnings"].append("Keine Daten zum Importieren gefunden")
|
||||
return result
|
||||
|
||||
for row_num, row in enumerate(rows, start=2): # Start at 2 (header is row 1)
|
||||
try:
|
||||
import_result = cls._import_single_booking(
|
||||
db_session, row, overwrite, row_num
|
||||
)
|
||||
|
||||
if import_result["status"] == "created":
|
||||
result["created"] += 1
|
||||
elif import_result["status"] == "updated":
|
||||
result["updated"] += 1
|
||||
elif import_result["status"] == "skipped_existing":
|
||||
result["skipped_existing"] += 1
|
||||
result["skipped"] += 1
|
||||
elif import_result["status"] == "skipped":
|
||||
result["skipped"] += 1
|
||||
if import_result.get("reason"):
|
||||
result["warnings"].append(
|
||||
f"Zeile {row_num}: {import_result['reason']}"
|
||||
)
|
||||
|
||||
if import_result.get("error"):
|
||||
result["errors"].append(
|
||||
f"Zeile {row_num}: {import_result['error']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error importing row {row_num}")
|
||||
result["errors"].append(f"Zeile {row_num}: {e!s}")
|
||||
|
||||
# Commit if we have any successful imports
|
||||
if result["created"] > 0 or result["updated"] > 0:
|
||||
try:
|
||||
db_session.commit()
|
||||
except Exception as e:
|
||||
db_session.rollback()
|
||||
result["success"] = False
|
||||
result["error"] = f"Datenbank-Fehler: {e!s}"
|
||||
result["created"] = 0
|
||||
result["updated"] = 0
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _import_single_booking(
|
||||
cls,
|
||||
db_session,
|
||||
row: dict,
|
||||
overwrite: bool,
|
||||
row_num: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Import a single booking row.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
row: Row dictionary
|
||||
overwrite: If True, update existing bookings
|
||||
row_num: Row number for error reporting
|
||||
|
||||
Returns:
|
||||
Single import result
|
||||
"""
|
||||
# Normalize field names
|
||||
normalized = cls._normalize_row(row)
|
||||
|
||||
# Validate required fields
|
||||
if not normalized.get("customer_email"):
|
||||
return {"status": "skipped", "reason": "Keine E-Mail-Adresse"}
|
||||
|
||||
# Find or create customer
|
||||
customer = cls._find_or_create_customer(db_session, normalized)
|
||||
|
||||
# Check for existing booking
|
||||
wp_booking_id = normalized.get("wp_booking_id")
|
||||
booking_number = normalized.get("booking_number")
|
||||
|
||||
existing = None
|
||||
if wp_booking_id:
|
||||
existing = (
|
||||
db_session.query(Booking)
|
||||
.filter(Booking.wp_booking_id == int(wp_booking_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing and booking_number:
|
||||
existing = (
|
||||
db_session.query(Booking)
|
||||
.filter(Booking.booking_number == str(booking_number))
|
||||
.first()
|
||||
)
|
||||
|
||||
# Overwrite protection
|
||||
if existing and not overwrite:
|
||||
return {
|
||||
"status": "skipped_existing",
|
||||
"reason": f"Buchung existiert bereits (ID: {existing.id})",
|
||||
}
|
||||
|
||||
# Create or update booking
|
||||
if existing:
|
||||
booking = existing
|
||||
status = "updated"
|
||||
else:
|
||||
# Generate wp_booking_id if not provided
|
||||
if not wp_booking_id:
|
||||
# Use negative IDs for imported bookings without WP ID
|
||||
max_negative = (
|
||||
db_session.query(Booking.wp_booking_id)
|
||||
.filter(Booking.wp_booking_id < 0)
|
||||
.order_by(Booking.wp_booking_id.asc())
|
||||
.first()
|
||||
)
|
||||
wp_booking_id = (max_negative[0] - 1) if max_negative else -1
|
||||
|
||||
booking = Booking(
|
||||
customer_id=customer.id,
|
||||
wp_booking_id=int(wp_booking_id),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db_session.add(booking)
|
||||
status = "created"
|
||||
|
||||
# Update booking fields
|
||||
cls._update_booking_fields(booking, normalized)
|
||||
booking.customer_id = customer.id
|
||||
booking.synced_at = datetime.now(UTC)
|
||||
booking.updated_at = datetime.now(UTC)
|
||||
|
||||
return {"status": status, "booking_id": booking.id if existing else None}
|
||||
|
||||
@classmethod
|
||||
def _normalize_row(cls, row: dict) -> dict:
|
||||
"""Normalize row field names to internal format.
|
||||
|
||||
Args:
|
||||
row: Raw row dictionary
|
||||
|
||||
Returns:
|
||||
Normalized dictionary
|
||||
"""
|
||||
normalized = {}
|
||||
custom_fields = {}
|
||||
|
||||
# Handle nested 'customer' object (MEC format)
|
||||
if "customer" in row and isinstance(row["customer"], dict):
|
||||
customer_data = row["customer"]
|
||||
if customer_data.get("email"):
|
||||
normalized["customer_email"] = customer_data["email"]
|
||||
if customer_data.get("name"):
|
||||
normalized["customer_name"] = customer_data["name"]
|
||||
if customer_data.get("phone"):
|
||||
normalized["customer_phone"] = customer_data["phone"]
|
||||
|
||||
for key, value in row.items():
|
||||
if value is None or value == "":
|
||||
continue
|
||||
|
||||
# Skip nested objects (already processed above)
|
||||
if isinstance(value, dict):
|
||||
continue
|
||||
|
||||
# Convert key to lowercase and strip
|
||||
key_lower = str(key).lower().strip()
|
||||
|
||||
# Check if it's a known field
|
||||
if key_lower in cls.FIELD_MAPPINGS:
|
||||
internal_key = cls.FIELD_MAPPINGS[key_lower]
|
||||
normalized[internal_key] = value
|
||||
else:
|
||||
# Store as custom field
|
||||
custom_fields[key] = value
|
||||
|
||||
if custom_fields:
|
||||
normalized["custom_fields"] = custom_fields
|
||||
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _find_or_create_customer(cls, db_session, data: dict) -> Customer:
|
||||
"""Find existing customer or create new one.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
data: Normalized booking data
|
||||
|
||||
Returns:
|
||||
Customer instance
|
||||
"""
|
||||
email = data.get("customer_email", "").lower().strip()
|
||||
|
||||
customer = db_session.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
if not customer:
|
||||
# Sprint 12: All customer data goes to custom_fields
|
||||
full_name = data.get("customer_name", "")
|
||||
phone = data.get("customer_phone", "")
|
||||
|
||||
# Store name and phone in custom_fields
|
||||
custom_fields = {}
|
||||
if full_name:
|
||||
custom_fields["name"] = full_name
|
||||
if phone:
|
||||
custom_fields["phone"] = phone
|
||||
|
||||
# Create customer with only required fields
|
||||
customer = Customer(
|
||||
email=email,
|
||||
custom_fields=custom_fields if custom_fields else None,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
db_session.add(customer)
|
||||
db_session.flush() # Get ID
|
||||
|
||||
return customer
|
||||
|
||||
@classmethod
|
||||
def _update_booking_fields(cls, booking: Booking, data: dict) -> None:
|
||||
"""Update booking fields from normalized data.
|
||||
|
||||
Args:
|
||||
booking: Booking instance
|
||||
data: Normalized data dictionary
|
||||
"""
|
||||
# Direct field mappings
|
||||
if data.get("wp_kurs_id"):
|
||||
booking.wp_kurs_id = int(data["wp_kurs_id"])
|
||||
|
||||
if data.get("booking_number"):
|
||||
booking.booking_number = str(data["booking_number"])
|
||||
|
||||
if data.get("kurs_title"):
|
||||
booking.kurs_title = str(data["kurs_title"])
|
||||
|
||||
if data.get("kurs_location"):
|
||||
booking.kurs_location = str(data["kurs_location"])
|
||||
|
||||
if data.get("ticket_type"):
|
||||
booking.ticket_type = str(data["ticket_type"])
|
||||
|
||||
if data.get("ticket_count"):
|
||||
try:
|
||||
booking.ticket_count = int(data["ticket_count"])
|
||||
except (ValueError, TypeError):
|
||||
booking.ticket_count = 1
|
||||
|
||||
# Status with mapping
|
||||
if data.get("status"):
|
||||
status_raw = str(data["status"]).lower().strip()
|
||||
booking.status = cls.STATUS_MAPPINGS.get(status_raw, status_raw)
|
||||
|
||||
# Price parsing
|
||||
if data.get("total_price"):
|
||||
booking.total_price = cls._parse_price(data["total_price"])
|
||||
|
||||
# Date parsing
|
||||
if data.get("kurs_date"):
|
||||
booking.kurs_date = cls._parse_date(data["kurs_date"])
|
||||
|
||||
# Time fields
|
||||
if data.get("kurs_time"):
|
||||
booking.kurs_time = cls._parse_time(data["kurs_time"])
|
||||
if data.get("kurs_end_time"):
|
||||
booking.kurs_end_time = cls._parse_time(data["kurs_end_time"])
|
||||
|
||||
# Customer snapshot
|
||||
if data.get("customer_name"):
|
||||
booking.customer_name = str(data["customer_name"])
|
||||
if data.get("customer_email"):
|
||||
booking.customer_email = str(data["customer_email"])
|
||||
if data.get("customer_phone"):
|
||||
booking.customer_phone = str(data["customer_phone"])
|
||||
|
||||
# sevDesk
|
||||
if data.get("sevdesk_invoice_id"):
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
booking.sevdesk_invoice_id = int(data["sevdesk_invoice_id"])
|
||||
if data.get("sevdesk_invoice_number"):
|
||||
booking.sevdesk_invoice_number = str(data["sevdesk_invoice_number"])
|
||||
|
||||
# WordPress created date
|
||||
if data.get("wp_created_at"):
|
||||
wp_created = cls._parse_datetime(data["wp_created_at"])
|
||||
if wp_created:
|
||||
booking.wp_created_at = wp_created
|
||||
|
||||
# Custom fields
|
||||
if data.get("custom_fields"):
|
||||
existing = booking.custom_fields or {}
|
||||
existing.update(data["custom_fields"])
|
||||
booking.custom_fields = existing
|
||||
|
||||
@classmethod
|
||||
def _parse_price(cls, value: Any) -> Decimal | None:
|
||||
"""Parse price value to Decimal.
|
||||
|
||||
Handles German format (1.234,56) and English format (1,234.56)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
return Decimal(str(value))
|
||||
|
||||
# String parsing
|
||||
price_str = str(value).strip()
|
||||
|
||||
# Remove currency symbols and whitespace
|
||||
for char in ["EUR", "€", "$", " "]:
|
||||
price_str = price_str.replace(char, "")
|
||||
|
||||
if not price_str:
|
||||
return None
|
||||
|
||||
# Detect format and normalize
|
||||
# German: 1.234,56 -> English: 1234.56
|
||||
if "," in price_str and "." in price_str:
|
||||
if price_str.rfind(",") > price_str.rfind("."):
|
||||
# German format: 1.234,56
|
||||
price_str = price_str.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
# English format: 1,234.56
|
||||
price_str = price_str.replace(",", "")
|
||||
elif "," in price_str:
|
||||
# Could be German decimal: 12,50
|
||||
price_str = price_str.replace(",", ".")
|
||||
|
||||
try:
|
||||
return Decimal(price_str)
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _parse_date(cls, value: Any):
|
||||
"""Parse date value to date object."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if hasattr(value, "date"):
|
||||
return value.date() if hasattr(value, "date") else value
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
|
||||
# Try various formats
|
||||
formats = [
|
||||
"%Y-%m-%d", # ISO: 2024-01-15
|
||||
"%d.%m.%Y", # German: 15.01.2024
|
||||
"%d/%m/%Y", # European: 15/01/2024
|
||||
"%m/%d/%Y", # US: 01/15/2024
|
||||
"%Y-%m-%d %H:%M:%S", # ISO with time
|
||||
"%d.%m.%Y %H:%M", # German with time
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _parse_time(cls, value: Any) -> str | None:
|
||||
"""Parse time value to HH:MM string."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if hasattr(value, "strftime"):
|
||||
return value.strftime("%H:%M")
|
||||
|
||||
time_str = str(value).strip()
|
||||
|
||||
# Already in correct format
|
||||
if len(time_str) == 5 and time_str[2] == ":":
|
||||
return time_str
|
||||
|
||||
# Handle H:MM format
|
||||
if len(time_str) == 4 and time_str[1] == ":":
|
||||
return f"0{time_str}"
|
||||
|
||||
# Handle HHMM format
|
||||
if len(time_str) == 4 and time_str.isdigit():
|
||||
return f"{time_str[:2]}:{time_str[2:]}"
|
||||
|
||||
return time_str[:5] if len(time_str) >= 5 else time_str
|
||||
|
||||
@classmethod
|
||||
def _parse_datetime(cls, value: Any):
|
||||
"""Parse datetime value."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
|
||||
formats = [
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%d.%m.%Y %H:%M:%S",
|
||||
"%d.%m.%Y %H:%M",
|
||||
"%Y-%m-%d",
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_import_template_csv(cls) -> str:
|
||||
"""Generate CSV template for import.
|
||||
|
||||
Returns:
|
||||
CSV template string
|
||||
"""
|
||||
headers = [
|
||||
"buchungsnummer",
|
||||
"email",
|
||||
"name",
|
||||
"telefon",
|
||||
"kurs",
|
||||
"datum",
|
||||
"uhrzeit",
|
||||
"ort",
|
||||
"status",
|
||||
"preis",
|
||||
"ticket_typ",
|
||||
"anzahl",
|
||||
]
|
||||
return ";".join(headers) + "\n"
|
||||
|
||||
@classmethod
|
||||
def get_import_template_json(cls) -> str:
|
||||
"""Generate JSON template for import.
|
||||
|
||||
Returns:
|
||||
JSON template string
|
||||
"""
|
||||
template = {
|
||||
"bookings": [
|
||||
{
|
||||
"id": 1234,
|
||||
"number": "KB-2024-0001",
|
||||
"customer": {
|
||||
"email": "kunde@example.com",
|
||||
"name": "Max Mustermann",
|
||||
"phone": "+43 123 456789",
|
||||
},
|
||||
"kurs_title": "Beispielkurs",
|
||||
"kurs_date": "2024-01-15",
|
||||
"kurs_time": "10:00",
|
||||
"kurs_location": "Wien",
|
||||
"status": "confirmed",
|
||||
"price": 150.00,
|
||||
"ticket_type": "Standard",
|
||||
"ticket_count": 1,
|
||||
"custom_fields": {
|
||||
"Zusatzfeld 1": "Wert 1",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
return json.dumps(template, indent=2, ensure_ascii=False)
|
||||
237
customer_portal/services/email.py
Executable file
237
customer_portal/services/email.py
Executable file
@@ -0,0 +1,237 @@
|
||||
"""Email service.
|
||||
|
||||
Uses database settings from Admin panel instead of environment variables.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import UTC, datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from flask import current_app, render_template, url_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_mail_config():
|
||||
"""Get mail configuration from database.
|
||||
|
||||
Returns:
|
||||
dict: Mail configuration from database or defaults
|
||||
"""
|
||||
try:
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.settings import PortalSettings
|
||||
|
||||
db = get_db()
|
||||
return PortalSettings.get_mail_config(db)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get mail config from database: {e}")
|
||||
# Fallback to app config (environment variables)
|
||||
return {
|
||||
"mail_server": current_app.config.get("MAIL_SERVER", ""),
|
||||
"mail_port": current_app.config.get("MAIL_PORT", 587),
|
||||
"mail_use_tls": current_app.config.get("MAIL_USE_TLS", True),
|
||||
"mail_use_ssl": current_app.config.get("MAIL_USE_SSL", False),
|
||||
"mail_username": current_app.config.get("MAIL_USERNAME", ""),
|
||||
"mail_password": current_app.config.get("MAIL_PASSWORD", ""),
|
||||
"mail_default_sender": current_app.config.get("MAIL_DEFAULT_SENDER", ""),
|
||||
"mail_default_sender_name": "Kundenportal",
|
||||
}
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html_body: str, text_body: str = "") -> bool:
|
||||
"""Send email using database SMTP settings.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
html_body: HTML content
|
||||
text_body: Plain text content (optional)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
config = get_mail_config()
|
||||
|
||||
if not config.get("mail_server"):
|
||||
logger.error("Mail server not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["To"] = to
|
||||
|
||||
# Build sender
|
||||
sender_name = config.get("mail_default_sender_name", "Kundenportal")
|
||||
sender_email = config.get("mail_default_sender", "")
|
||||
if sender_name and sender_email:
|
||||
msg["From"] = f"{sender_name} <{sender_email}>"
|
||||
else:
|
||||
msg["From"] = sender_email
|
||||
|
||||
# Add plain text
|
||||
if text_body:
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
# Add HTML
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# Connect to SMTP server
|
||||
server = config.get("mail_server")
|
||||
port = config.get("mail_port", 587)
|
||||
use_ssl = config.get("mail_use_ssl", False)
|
||||
use_tls = config.get("mail_use_tls", True)
|
||||
|
||||
if use_ssl:
|
||||
smtp = smtplib.SMTP_SSL(server, port)
|
||||
else:
|
||||
smtp = smtplib.SMTP(server, port)
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
|
||||
# Login if credentials provided
|
||||
username = config.get("mail_username")
|
||||
password = config.get("mail_password")
|
||||
if username and password:
|
||||
smtp.login(username, password)
|
||||
|
||||
# Send
|
||||
smtp.sendmail(sender_email, [to], msg.as_string())
|
||||
smtp.quit()
|
||||
|
||||
logger.info(f"Email sent to {to}: {subject}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Send emails using database SMTP settings."""
|
||||
|
||||
SUBJECT_MAP = {
|
||||
"login": "Ihr Login-Code fuer das Kundenportal",
|
||||
"register": "Willkommen - Ihre Registrierung",
|
||||
"reset": "Ihr Code zum Zuruecksetzen",
|
||||
"prefill": "Ihr Code fuer die Buchung",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def send_otp(email: str, code: str, purpose: str = "login") -> bool:
|
||||
"""Send OTP code via email.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
code: OTP code
|
||||
purpose: OTP purpose (login, register, reset, prefill)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
subject = EmailService.SUBJECT_MAP.get(purpose, "Ihr Code")
|
||||
|
||||
html_body = render_template(
|
||||
"emails/otp.html",
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
text_body = f"""
|
||||
Ihr Code: {code}
|
||||
|
||||
Dieser Code ist 10 Minuten gueltig.
|
||||
|
||||
Falls Sie diesen Code nicht angefordert haben, ignorieren Sie diese E-Mail.
|
||||
"""
|
||||
|
||||
success = send_email(email, subject, html_body, text_body)
|
||||
|
||||
if success:
|
||||
logger.info(f"OTP email sent to {email} for {purpose}")
|
||||
# In development, log the code for testing
|
||||
elif current_app.debug:
|
||||
logger.warning(f"DEBUG MODE - OTP code for {email}: {code}")
|
||||
|
||||
return success
|
||||
|
||||
@staticmethod
|
||||
def send_login_notification(email: str, ip_address: str, user_agent: str) -> bool:
|
||||
"""Send notification about new login.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
ip_address: Login IP address
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
html_body = render_template(
|
||||
"emails/login_notification.html",
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return send_email(email, "Neue Anmeldung in Ihrem Kundenportal", html_body)
|
||||
|
||||
@staticmethod
|
||||
def send_welcome(email: str, name: str) -> bool:
|
||||
"""Send welcome email after registration.
|
||||
|
||||
Args:
|
||||
email: Recipient email address
|
||||
name: Customer name
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
# Get portal URL from config or build it
|
||||
portal_url = current_app.config.get("PORTAL_URL", "")
|
||||
if not portal_url:
|
||||
try:
|
||||
portal_url = url_for("main.index", _external=True)
|
||||
except RuntimeError:
|
||||
portal_url = "http://localhost:8502"
|
||||
|
||||
html_body = render_template(
|
||||
"emails/welcome.html",
|
||||
name=name,
|
||||
portal_url=portal_url,
|
||||
year=datetime.now(UTC).year,
|
||||
)
|
||||
|
||||
text_body = f"""
|
||||
Willkommen, {name}!
|
||||
|
||||
Ihr Kundenkonto wurde erfolgreich erstellt.
|
||||
|
||||
Im Kundenportal koennen Sie:
|
||||
- Ihre Buchungen einsehen
|
||||
- Rechnungen herunterladen
|
||||
- Videos ansehen
|
||||
- Stornierungen beantragen
|
||||
|
||||
Zum Portal: {portal_url}
|
||||
"""
|
||||
|
||||
success = send_email(
|
||||
email, "Willkommen im Kundenportal - Webwerkstatt", html_body, text_body
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Welcome email sent to {email}")
|
||||
elif current_app.debug:
|
||||
logger.warning(f"DEBUG MODE - Welcome email would be sent to {email}")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
# Keep Flask-Mail for backwards compatibility (but not used anymore)
|
||||
from flask_mail import Mail
|
||||
|
||||
mail = Mail()
|
||||
109
customer_portal/services/otp.py
Executable file
109
customer_portal/services/otp.py
Executable file
@@ -0,0 +1,109 @@
|
||||
"""OTP service."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from customer_portal.models.otp import OTPCode
|
||||
|
||||
|
||||
class OTPService:
|
||||
"""Generate and verify OTP codes."""
|
||||
|
||||
OTP_LENGTH = 6
|
||||
OTP_LIFETIME_MINUTES = 10
|
||||
|
||||
@staticmethod
|
||||
def generate() -> str:
|
||||
"""Generate random 6-digit OTP."""
|
||||
return "".join(
|
||||
secrets.choice("0123456789") for _ in range(OTPService.OTP_LENGTH)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_for_customer(db_session, customer_id: int, purpose: str) -> str:
|
||||
"""Create OTP for customer.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
purpose: OTP purpose (login, register, reset)
|
||||
|
||||
Returns:
|
||||
Generated OTP code
|
||||
"""
|
||||
# Invalidate any existing unused OTPs for same purpose
|
||||
db_session.query(OTPCode).filter(
|
||||
OTPCode.customer_id == customer_id,
|
||||
OTPCode.purpose == purpose,
|
||||
OTPCode.used_at.is_(None),
|
||||
).update({"used_at": datetime.now(UTC)})
|
||||
|
||||
code = OTPService.generate()
|
||||
expires_at = datetime.now(UTC) + timedelta(
|
||||
minutes=OTPService.OTP_LIFETIME_MINUTES
|
||||
)
|
||||
|
||||
otp = OTPCode(
|
||||
customer_id=customer_id,
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db_session.add(otp)
|
||||
db_session.commit()
|
||||
|
||||
return code
|
||||
|
||||
@staticmethod
|
||||
def verify(db_session, customer_id: int, code: str, purpose: str) -> bool:
|
||||
"""Verify OTP code.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
code: OTP code to verify
|
||||
purpose: OTP purpose
|
||||
|
||||
Returns:
|
||||
True if code is valid, False otherwise
|
||||
"""
|
||||
otp = (
|
||||
db_session.query(OTPCode)
|
||||
.filter(
|
||||
OTPCode.customer_id == customer_id,
|
||||
OTPCode.code == code,
|
||||
OTPCode.purpose == purpose,
|
||||
OTPCode.used_at.is_(None),
|
||||
OTPCode.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if otp:
|
||||
otp.used_at = datetime.now(UTC)
|
||||
db_session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def count_recent_attempts(db_session, customer_id: int, minutes: int = 60) -> int:
|
||||
"""Count recent OTP attempts for rate limiting.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer_id: Customer ID
|
||||
minutes: Time window in minutes
|
||||
|
||||
Returns:
|
||||
Number of OTPs created in time window
|
||||
"""
|
||||
since = datetime.now(UTC) - timedelta(minutes=minutes)
|
||||
return (
|
||||
db_session.query(OTPCode)
|
||||
.filter(
|
||||
OTPCode.customer_id == customer_id,
|
||||
OTPCode.created_at >= since,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
147
customer_portal/services/token.py
Executable file
147
customer_portal/services/token.py
Executable file
@@ -0,0 +1,147 @@
|
||||
"""Token service for secure form pre-filling.
|
||||
|
||||
Generates signed tokens for cross-system communication between
|
||||
Customer Portal and WordPress kurs-booking plugin.
|
||||
|
||||
Sprint 6.6: Extended to include all custom_fields for dynamic sync.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""Generate and validate signed tokens for cross-system communication."""
|
||||
|
||||
@staticmethod
|
||||
def generate_prefill_token(customer: Any) -> str:
|
||||
"""
|
||||
Generate signed token for WordPress form pre-fill.
|
||||
|
||||
The token contains all customer data (core fields + custom_fields)
|
||||
signed with the shared WP_API_SECRET. WordPress validates the
|
||||
signature and uses the data to pre-fill the booking form.
|
||||
|
||||
Sprint 6.6: Now includes all custom_fields for complete sync.
|
||||
|
||||
Args:
|
||||
customer: Customer model instance with id, name, email, phone, custom_fields
|
||||
|
||||
Returns:
|
||||
Base64-URL-safe encoded signed token string
|
||||
|
||||
Raises:
|
||||
ValueError: If WP_API_SECRET is not configured
|
||||
"""
|
||||
secret = current_app.config.get("WP_API_SECRET", "")
|
||||
expiry = current_app.config.get("PREFILL_TOKEN_EXPIRY", 300)
|
||||
|
||||
if not secret:
|
||||
raise ValueError("WP_API_SECRET not configured")
|
||||
|
||||
# Sprint 12: Use display properties - all data from custom_fields
|
||||
addr = customer.display_address
|
||||
payload = {
|
||||
"customer_id": customer.id,
|
||||
"name": customer.display_name,
|
||||
"email": customer.email or "",
|
||||
"phone": customer.display_phone,
|
||||
"address_street": addr.get("street", ""),
|
||||
"address_city": addr.get("city", ""),
|
||||
"address_zip": addr.get("zip", ""),
|
||||
"exp": int(time.time()) + expiry,
|
||||
}
|
||||
|
||||
# Add all custom_fields to payload for full data access
|
||||
custom_fields = customer.get_custom_fields()
|
||||
if custom_fields:
|
||||
payload["custom_fields"] = custom_fields
|
||||
|
||||
# JSON encode with minimal whitespace.
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
# Create HMAC-SHA256 signature.
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
# Combine payload and signature.
|
||||
token_data = f"{payload_json}.{signature}"
|
||||
|
||||
# Base64 URL-safe encode.
|
||||
return base64.urlsafe_b64encode(token_data.encode("utf-8")).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def validate_prefill_token(token: str) -> dict | None:
|
||||
"""
|
||||
Validate a prefill token and extract customer data.
|
||||
|
||||
This method is primarily for testing purposes. The actual validation
|
||||
happens in WordPress (class-frontend.php).
|
||||
|
||||
Args:
|
||||
token: Base64-URL-safe encoded signed token
|
||||
|
||||
Returns:
|
||||
Dictionary with customer data if valid, None otherwise
|
||||
"""
|
||||
secret = current_app.config.get("WP_API_SECRET", "")
|
||||
|
||||
if not secret:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decode base64.
|
||||
decoded = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
# Split payload and signature.
|
||||
parts = decoded.split(".", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
payload_json, signature = parts
|
||||
|
||||
# Verify signature.
|
||||
expected_sig = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(expected_sig, signature):
|
||||
return None
|
||||
|
||||
# Parse payload.
|
||||
payload = json.loads(payload_json)
|
||||
|
||||
# Check expiration.
|
||||
if payload.get("exp", 0) < time.time():
|
||||
return None
|
||||
|
||||
# Sprint 6.6: Return all fields including custom_fields
|
||||
result = {
|
||||
"customer_id": payload.get("customer_id"),
|
||||
"name": payload.get("name", ""),
|
||||
"email": payload.get("email", ""),
|
||||
"phone": payload.get("phone", ""),
|
||||
"address_street": payload.get("address_street", ""),
|
||||
"address_city": payload.get("address_city", ""),
|
||||
"address_zip": payload.get("address_zip", ""),
|
||||
}
|
||||
|
||||
# Include custom_fields if present
|
||||
if "custom_fields" in payload:
|
||||
result["custom_fields"] = payload["custom_fields"]
|
||||
|
||||
return result
|
||||
|
||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
555
customer_portal/services/wordpress_api.py
Executable file
555
customer_portal/services/wordpress_api.py
Executable file
@@ -0,0 +1,555 @@
|
||||
"""WordPress API client and webhook handler."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from flask import current_app
|
||||
|
||||
from customer_portal.models.customer import Customer
|
||||
from customer_portal.services.email import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WordPressWebhook:
|
||||
"""Handle webhooks from WordPress."""
|
||||
|
||||
@staticmethod
|
||||
def handle_booking_created(db_session, data: dict) -> dict:
|
||||
"""Auto-register customer on booking.
|
||||
|
||||
Called when a booking is created in WordPress.
|
||||
Creates customer if not exists, sends welcome email.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
data: Webhook payload with email, name, phone
|
||||
|
||||
Returns:
|
||||
Dict with customer_id or error
|
||||
"""
|
||||
email = data.get("email", "").strip().lower()
|
||||
name = data.get("name", "").strip()
|
||||
phone = data.get("phone", "").strip() if data.get("phone") else None
|
||||
|
||||
if not email or "@" not in email:
|
||||
logger.warning("Webhook received without valid email")
|
||||
return {"error": "Email required"}
|
||||
|
||||
# Check if customer exists
|
||||
customer = db_session.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
if customer:
|
||||
logger.info(f"Customer already exists: {email}")
|
||||
return {
|
||||
"customer_id": customer.id,
|
||||
"created": False,
|
||||
"message": "Customer already exists",
|
||||
}
|
||||
|
||||
# Create new customer with defaults from settings
|
||||
customer = Customer.create_with_defaults(
|
||||
db_session,
|
||||
email=email,
|
||||
name=name if name else email.split("@")[0],
|
||||
phone=phone,
|
||||
)
|
||||
db_session.add(customer)
|
||||
db_session.commit()
|
||||
|
||||
logger.info(f"New customer created via webhook: {email}")
|
||||
|
||||
# Send welcome email (Sprint 12: use display_name)
|
||||
try:
|
||||
EmailService.send_welcome(email, customer.display_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send welcome email to {email}: {e}")
|
||||
|
||||
return {
|
||||
"customer_id": customer.id,
|
||||
"created": True,
|
||||
"message": "Customer created successfully",
|
||||
}
|
||||
|
||||
|
||||
class WordPressAPI:
|
||||
"""Client for WordPress REST API."""
|
||||
|
||||
@staticmethod
|
||||
def _get_client() -> httpx.Client:
|
||||
"""Get configured HTTP client.
|
||||
|
||||
Returns:
|
||||
httpx.Client configured with base URL and auth headers.
|
||||
"""
|
||||
base_url = current_app.config.get("WP_API_URL", "")
|
||||
secret = current_app.config.get("WP_API_SECRET", "")
|
||||
|
||||
if not base_url:
|
||||
raise ValueError("WP_API_URL not configured")
|
||||
|
||||
return httpx.Client(
|
||||
base_url=base_url,
|
||||
headers={
|
||||
"X-Portal-Secret": secret,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_bookings(email: str) -> list[dict[str, Any]]:
|
||||
"""Get bookings for customer.
|
||||
|
||||
Args:
|
||||
email: Customer email address
|
||||
|
||||
Returns:
|
||||
List of booking dictionaries
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get("/bookings", params={"email": email})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error fetching bookings: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching bookings for {email}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_booking(booking_id: int, email: str | None = None) -> dict[str, Any] | None:
|
||||
"""Get single booking details.
|
||||
|
||||
Args:
|
||||
booking_id: Booking ID
|
||||
email: Customer email for verification
|
||||
|
||||
Returns:
|
||||
Booking dictionary or None if not found
|
||||
"""
|
||||
try:
|
||||
params = {"email": email} if email else {}
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get(f"/bookings/{booking_id}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
if e.response.status_code == 403:
|
||||
logger.warning(f"Access denied to booking {booking_id}")
|
||||
return None
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching booking {booking_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def cancel_booking(booking_id: int, email: str, reason: str = "") -> dict[str, Any]:
|
||||
"""Request booking cancellation.
|
||||
|
||||
Args:
|
||||
booking_id: Booking ID
|
||||
email: Customer email for verification
|
||||
reason: Cancellation reason
|
||||
|
||||
Returns:
|
||||
Response dictionary with success status
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.post(
|
||||
f"/bookings/{booking_id}/cancel",
|
||||
json={"email": email, "reason": reason},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_data = {"success": False, "error": str(e)}
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
except Exception:
|
||||
pass
|
||||
return error_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling booking {booking_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def get_invoices(email: str) -> list[dict[str, Any]]:
|
||||
"""Get invoices for customer.
|
||||
|
||||
Args:
|
||||
email: Customer email address
|
||||
|
||||
Returns:
|
||||
List of invoice dictionaries
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get("/invoices", params={"email": email})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 503:
|
||||
logger.info("sevDesk not configured in WordPress")
|
||||
return []
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching invoices for {email}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_invoice_pdf(invoice_id: int, email: str) -> dict[str, Any] | None:
|
||||
"""Get invoice PDF from WordPress/sevDesk.
|
||||
|
||||
Args:
|
||||
invoice_id: sevDesk invoice ID
|
||||
email: Customer email for ownership verification
|
||||
|
||||
Returns:
|
||||
Dict with pdf (base64), filename, encoding or None on error
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get(
|
||||
f"/invoices/{invoice_id}/pdf", params={"email": email}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
logger.warning(f"Access denied to invoice PDF {invoice_id}")
|
||||
return None
|
||||
if e.response.status_code == 503:
|
||||
logger.info("sevDesk not configured in WordPress")
|
||||
return None
|
||||
logger.error(f"HTTP error fetching invoice PDF: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching invoice PDF {invoice_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_videos(email: str) -> list[dict[str, Any]]:
|
||||
"""Get videos available to customer.
|
||||
|
||||
Args:
|
||||
email: Customer email address
|
||||
|
||||
Returns:
|
||||
List of video dictionaries
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get("/videos", params={"email": email})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 503:
|
||||
logger.info("Video module not active in WordPress")
|
||||
return []
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching videos for {email}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_video_stream(video_id: int, email: str) -> dict[str, Any] | None:
|
||||
"""Get video stream URL from WordPress.
|
||||
|
||||
WordPress handles the Video-Service token generation internally.
|
||||
|
||||
Args:
|
||||
video_id: WordPress video post ID
|
||||
email: Customer email for access verification
|
||||
|
||||
Returns:
|
||||
Dict with stream_url or error, None on failure
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get(
|
||||
f"/videos/{video_id}/stream",
|
||||
params={"email": email},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
logger.warning(f"Access denied to video stream {video_id}")
|
||||
return {"error": "Kein Zugriff auf dieses Video"}
|
||||
if e.response.status_code == 404:
|
||||
return {"error": "Video nicht gefunden"}
|
||||
if e.response.status_code == 503:
|
||||
logger.info("Video service not available")
|
||||
return {"error": "Video-Service nicht verfuegbar"}
|
||||
logger.error(f"HTTP error fetching video stream: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching video stream {video_id}: {e}")
|
||||
return None
|
||||
|
||||
# Sprint 6.6: Schema and field synchronization methods
|
||||
|
||||
@staticmethod
|
||||
def get_schema(kurs_id: int | None = None) -> dict[str, Any]:
|
||||
"""Get booking form field schema from WordPress.
|
||||
|
||||
Retrieves all field definitions (core + custom) from WordPress.
|
||||
This enables dynamic form generation in the portal.
|
||||
|
||||
Args:
|
||||
kurs_id: Optional kurs ID for kurs-specific fields
|
||||
|
||||
Returns:
|
||||
Schema dictionary with core_fields, custom_fields, editable
|
||||
"""
|
||||
try:
|
||||
params = {}
|
||||
if kurs_id:
|
||||
params["kurs_id"] = kurs_id
|
||||
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.get("/schema", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error fetching schema: {e}")
|
||||
return {"core_fields": [], "custom_fields": [], "editable": {}}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching schema: {e}")
|
||||
return {"core_fields": [], "custom_fields": [], "editable": {}}
|
||||
|
||||
@staticmethod
|
||||
def update_booking(
|
||||
booking_id: int, email: str, fields: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Update booking fields via PATCH request.
|
||||
|
||||
Only editable fields (as defined by WordPress) can be updated.
|
||||
|
||||
Args:
|
||||
booking_id: Booking ID to update
|
||||
email: Customer email for ownership verification
|
||||
fields: Dictionary of field names and values to update
|
||||
|
||||
Returns:
|
||||
Response dictionary with success status and updated fields
|
||||
"""
|
||||
try:
|
||||
with WordPressAPI._get_client() as client:
|
||||
response = client.patch(
|
||||
f"/bookings/{booking_id}",
|
||||
json={"email": email, "fields": fields},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_data = {"success": False, "error": str(e)}
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
except Exception:
|
||||
pass
|
||||
return error_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating booking {booking_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# Consent fields that should NOT be overwritten for existing customers
|
||||
CONSENT_FIELDS = {
|
||||
"mec_field_11", # AGB akzeptiert
|
||||
"mec_field_12", # Buchungsbestaetigung
|
||||
"mec_field_14", # Kurserinnerung
|
||||
"mec_field_15", # Rechnung per E-Mail
|
||||
"mec_field_16", # Marketing
|
||||
"mec_field_17", # Newsletter
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sync_customer_fields_from_booking(customer, booking_id: int) -> dict[str, Any]:
|
||||
"""Sync custom fields from a booking to customer profile.
|
||||
|
||||
Fetches booking data and updates customer's custom_fields
|
||||
with values from the booking.
|
||||
|
||||
IMPORTANT: Consent fields (AGB, Marketing, Newsletter, etc.) are
|
||||
NEVER overwritten for existing customers. The portal is the
|
||||
master source for these preferences.
|
||||
|
||||
Args:
|
||||
customer: Customer model instance
|
||||
booking_id: Booking ID to sync from
|
||||
|
||||
Returns:
|
||||
Dictionary with synced fields
|
||||
"""
|
||||
booking = WordPressAPI.get_booking(booking_id, customer.email)
|
||||
if not booking:
|
||||
return {}
|
||||
|
||||
synced = {}
|
||||
|
||||
# Sync custom_fields from booking
|
||||
custom_fields = booking.get("custom_fields", {})
|
||||
if custom_fields:
|
||||
existing = customer.get_custom_fields()
|
||||
|
||||
# Check if customer already has consent fields (= existing customer)
|
||||
has_existing_consent = any(
|
||||
key in existing for key in WordPressAPI.CONSENT_FIELDS
|
||||
)
|
||||
|
||||
for key, value in custom_fields.items():
|
||||
# Never overwrite consent fields for existing customers
|
||||
if has_existing_consent and key in WordPressAPI.CONSENT_FIELDS:
|
||||
logger.debug(f"Skipping consent field {key} for existing customer")
|
||||
continue
|
||||
existing[key] = value
|
||||
synced[key] = value
|
||||
|
||||
customer.set_custom_fields(existing)
|
||||
|
||||
# Sprint 12: Sync phone into custom_fields (not fixed column)
|
||||
phone = booking.get("customer", {}).get("phone")
|
||||
if phone and not existing.get("phone"):
|
||||
existing["phone"] = phone
|
||||
synced["phone"] = phone
|
||||
customer.set_custom_fields(existing)
|
||||
|
||||
return synced
|
||||
|
||||
@staticmethod
|
||||
def sync_bookings_for_customer(db_session, customer) -> dict[str, Any]:
|
||||
"""Synchronize all bookings for a customer from WordPress.
|
||||
|
||||
Fetches all bookings from WordPress API and creates/updates
|
||||
them in the local database.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
customer: Customer model instance
|
||||
|
||||
Returns:
|
||||
Dictionary with sync results (created, updated, errors)
|
||||
"""
|
||||
from customer_portal.models.booking import Booking
|
||||
|
||||
result = {
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": [],
|
||||
"bookings": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Get basic booking list
|
||||
bookings_list = WordPressAPI.get_bookings(customer.email)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch bookings for {customer.email}: {e}")
|
||||
result["errors"].append(f"API error: {str(e)}")
|
||||
return result
|
||||
|
||||
for booking_summary in bookings_list:
|
||||
wp_booking_id = booking_summary.get("id")
|
||||
if not wp_booking_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get full booking details
|
||||
booking_detail = WordPressAPI.get_booking(wp_booking_id, customer.email)
|
||||
if not booking_detail:
|
||||
result["errors"].append(
|
||||
f"Could not fetch details for booking {wp_booking_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create or update in local DB
|
||||
booking, is_new = Booking.create_or_update_from_wp(
|
||||
db_session, customer.id, booking_detail
|
||||
)
|
||||
|
||||
if is_new:
|
||||
result["created"] += 1
|
||||
else:
|
||||
result["updated"] += 1
|
||||
|
||||
result["bookings"].append(
|
||||
{
|
||||
"id": booking.id,
|
||||
"wp_id": wp_booking_id,
|
||||
"number": booking.booking_number,
|
||||
"is_new": is_new,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing booking {wp_booking_id}: {e}")
|
||||
result["errors"].append(f"Booking {wp_booking_id}: {str(e)}")
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
db_session.commit()
|
||||
except Exception as e:
|
||||
db_session.rollback()
|
||||
logger.error(f"Database commit failed: {e}")
|
||||
result["errors"].append(f"Database error: {str(e)}")
|
||||
|
||||
logger.info(
|
||||
f"Synced bookings for {customer.email}: "
|
||||
f"{result['created']} created, {result['updated']} updated"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def sync_all_customer_bookings(db_session) -> dict[str, Any]:
|
||||
"""Synchronize bookings for all customers.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary with overall sync results
|
||||
"""
|
||||
from customer_portal.models.customer import Customer
|
||||
|
||||
customers = db_session.query(Customer).all()
|
||||
total_result = {
|
||||
"customers_synced": 0,
|
||||
"total_created": 0,
|
||||
"total_updated": 0,
|
||||
"errors": [],
|
||||
"per_customer": [],
|
||||
}
|
||||
|
||||
for customer in customers:
|
||||
result = WordPressAPI.sync_bookings_for_customer(db_session, customer)
|
||||
|
||||
total_result["customers_synced"] += 1
|
||||
total_result["total_created"] += result["created"]
|
||||
total_result["total_updated"] += result["updated"]
|
||||
|
||||
if result["errors"]:
|
||||
total_result["errors"].extend(result["errors"])
|
||||
|
||||
total_result["per_customer"].append(
|
||||
{
|
||||
"email": customer.email,
|
||||
"name": customer.display_name,
|
||||
"created": result["created"],
|
||||
"updated": result["updated"],
|
||||
"errors": len(result["errors"]),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Full sync complete: {total_result['customers_synced']} customers, "
|
||||
f"{total_result['total_created']} created, {total_result['total_updated']} updated"
|
||||
)
|
||||
|
||||
return total_result
|
||||
1
customer_portal/utils/__init__.py
Executable file
1
customer_portal/utils/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Utility functions."""
|
||||
1
customer_portal/web/__init__.py
Executable file
1
customer_portal/web/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Web application package."""
|
||||
BIN
customer_portal/web/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/web/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/web/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/app.cpython-311.pyc
Executable file
BIN
customer_portal/web/__pycache__/app.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/app.cpython-312.pyc
Executable file
BIN
customer_portal/web/__pycache__/app.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/filters.cpython-311.pyc
Executable file
BIN
customer_portal/web/__pycache__/filters.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/__pycache__/filters.cpython-312.pyc
Executable file
BIN
customer_portal/web/__pycache__/filters.cpython-312.pyc
Executable file
Binary file not shown.
93
customer_portal/web/app.py
Executable file
93
customer_portal/web/app.py
Executable 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
154
customer_portal/web/filters.py
Executable 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")
|
||||
1
customer_portal/web/routes/__init__.py
Executable file
1
customer_portal/web/routes/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
"""Route blueprints."""
|
||||
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/admin.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/admin.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/admin.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/admin.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/api.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/api.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/api.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/api.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/auth.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/auth.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/auth.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/auth.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/bookings.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/invoices.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/main.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/main.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/profile.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/profile.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/profile.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/profile.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/videos.cpython-311.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/videos.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/web/routes/__pycache__/videos.cpython-312.pyc
Executable file
BIN
customer_portal/web/routes/__pycache__/videos.cpython-312.pyc
Executable file
Binary file not shown.
2079
customer_portal/web/routes/admin.py
Executable file
2079
customer_portal/web/routes/admin.py
Executable file
File diff suppressed because it is too large
Load Diff
502
customer_portal/web/routes/api.py
Executable file
502
customer_portal/web/routes/api.py
Executable 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})
|
||||
332
customer_portal/web/routes/auth.py
Executable file
332
customer_portal/web/routes/auth.py
Executable 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)
|
||||
176
customer_portal/web/routes/bookings.py
Executable file
176
customer_portal/web/routes/bookings.py
Executable 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)
|
||||
119
customer_portal/web/routes/invoices.py
Executable file
119
customer_portal/web/routes/invoices.py
Executable file
@@ -0,0 +1,119 @@
|
||||
"""Invoice routes."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("invoices", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def list_invoices():
|
||||
"""List customer invoices."""
|
||||
try:
|
||||
invoices = WordPressAPI.get_invoices(g.customer.email)
|
||||
except ValueError as e:
|
||||
logger.error(f"WordPress API not configured: {e}")
|
||||
flash("WordPress-Verbindung nicht konfiguriert.", "error")
|
||||
invoices = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching invoices: {e}")
|
||||
flash("Rechnungen konnten nicht geladen werden.", "error")
|
||||
invoices = []
|
||||
|
||||
# Extract available years for filter
|
||||
years = set()
|
||||
for invoice in invoices:
|
||||
if invoice.get("created_at"):
|
||||
try:
|
||||
date_str = invoice["created_at"]
|
||||
year = date_str[:4] if "T" in date_str else date_str[:4]
|
||||
years.add(year)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sort years descending
|
||||
years = sorted(years, reverse=True)
|
||||
|
||||
# Get filter parameters
|
||||
status_filter = request.args.get("status", "")
|
||||
year_filter = request.args.get("year", "")
|
||||
|
||||
# Apply filters
|
||||
filtered_invoices = invoices
|
||||
if status_filter:
|
||||
filtered_invoices = [
|
||||
inv for inv in filtered_invoices if inv.get("status") == status_filter
|
||||
]
|
||||
if year_filter:
|
||||
filtered_invoices = [
|
||||
inv
|
||||
for inv in filtered_invoices
|
||||
if inv.get("created_at", "")[:4] == year_filter
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"invoices/list.html",
|
||||
invoices=filtered_invoices,
|
||||
all_invoices=invoices,
|
||||
years=years,
|
||||
status_filter=status_filter,
|
||||
year_filter=year_filter,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<int:invoice_id>/pdf")
|
||||
@login_required
|
||||
def download_pdf(invoice_id: int):
|
||||
"""Download invoice PDF from WordPress/sevDesk."""
|
||||
try:
|
||||
# Get PDF via WordPress API (includes ownership verification)
|
||||
pdf_data = WordPressAPI.get_invoice_pdf(invoice_id, g.customer.email)
|
||||
except ValueError as e:
|
||||
logger.error(f"WordPress API not configured: {e}")
|
||||
flash("WordPress-Verbindung nicht konfiguriert.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching invoice PDF {invoice_id}: {e}")
|
||||
flash("PDF konnte nicht geladen werden.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
if not pdf_data:
|
||||
flash("Rechnung nicht gefunden oder kein Zugriff.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
# Check for error response
|
||||
if "error" in pdf_data:
|
||||
flash(f"Fehler: {pdf_data['error']}", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
# Decode base64 PDF
|
||||
try:
|
||||
pdf_content = base64.b64decode(pdf_data["pdf"])
|
||||
filename = pdf_data.get("filename", f"Rechnung_{invoice_id}.pdf")
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding PDF: {e}")
|
||||
flash("PDF konnte nicht dekodiert werden.", "error")
|
||||
return redirect(url_for("invoices.list_invoices"))
|
||||
|
||||
# Return PDF as download
|
||||
return Response(
|
||||
pdf_content,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
90
customer_portal/web/routes/main.py
Executable file
90
customer_portal/web/routes/main.py
Executable file
@@ -0,0 +1,90 @@
|
||||
"""Main routes."""
|
||||
|
||||
from flask import Blueprint, g, jsonify, redirect, render_template, url_for
|
||||
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
"""Home page - redirect to dashboard or login."""
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/health")
|
||||
def health():
|
||||
"""Health check for Docker and load balancers.
|
||||
|
||||
Returns:
|
||||
- 200 OK if database connection works
|
||||
- 503 Service Unavailable if database is down
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from customer_portal.models import get_db
|
||||
|
||||
try:
|
||||
db = get_db()
|
||||
db.execute(text("SELECT 1"))
|
||||
return (
|
||||
jsonify({"status": "healthy", "database": "connected", "version": "1.0.0"}),
|
||||
200,
|
||||
)
|
||||
except Exception as e:
|
||||
return (
|
||||
jsonify(
|
||||
{"status": "unhealthy", "database": "disconnected", "error": str(e)}
|
||||
),
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/api/status")
|
||||
def api_status():
|
||||
"""API status endpoint."""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Kundenportal laeuft",
|
||||
"version": "0.1.0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard."""
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.booking import Booking
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Get upcoming bookings (confirmed, future dates)
|
||||
from datetime import date
|
||||
|
||||
upcoming_bookings = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.customer_id == g.customer.id,
|
||||
Booking.status == "confirmed",
|
||||
Booking.kurs_date >= date.today(),
|
||||
)
|
||||
.order_by(Booking.kurs_date.asc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get recent bookings count
|
||||
total_bookings = (
|
||||
db.query(Booking).filter(Booking.customer_id == g.customer.id).count()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
customer=g.customer,
|
||||
upcoming_bookings=upcoming_bookings,
|
||||
total_bookings=total_bookings,
|
||||
)
|
||||
187
customer_portal/web/routes/profile.py
Executable file
187
customer_portal/web/routes/profile.py
Executable file
@@ -0,0 +1,187 @@
|
||||
"""Profile routes with dynamic WordPress field synchronization.
|
||||
|
||||
Sprint 6.6: Dynamic fields from WordPress schema.
|
||||
Sprint 10: Admin-configurable field visibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||
|
||||
from customer_portal.models import get_db
|
||||
from customer_portal.models.settings import PortalSettings
|
||||
from customer_portal.services.wordpress_api import WordPressAPI
|
||||
from customer_portal.web.routes.auth import login_required
|
||||
|
||||
bp = Blueprint("profile", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def show():
|
||||
"""Display profile with dynamic fields from WordPress schema."""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
# Get field configuration from admin settings
|
||||
field_config = PortalSettings.get_field_config(db)
|
||||
|
||||
# Fetch schema from WordPress (only if custom fields are enabled)
|
||||
schema = {"core_fields": [], "custom_fields": [], "editable": {}}
|
||||
if field_config.get("custom_fields_visible"):
|
||||
try:
|
||||
schema = WordPressAPI.get_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch schema: {e}")
|
||||
|
||||
# Get current custom field values
|
||||
custom_fields = customer.get_custom_fields()
|
||||
|
||||
return render_template(
|
||||
"profile/show.html",
|
||||
customer=customer,
|
||||
schema=schema,
|
||||
custom_fields=custom_fields,
|
||||
field_config=field_config,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit():
|
||||
"""Edit profile with dynamic fields."""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
# Get field configuration from admin settings
|
||||
field_config = PortalSettings.get_field_config(db)
|
||||
|
||||
# Fetch schema from WordPress (only if custom fields are enabled)
|
||||
schema = {"core_fields": [], "custom_fields": [], "editable": {}}
|
||||
if field_config.get("custom_fields_visible"):
|
||||
try:
|
||||
schema = WordPressAPI.get_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch schema: {e}")
|
||||
|
||||
if request.method == "POST":
|
||||
# Sprint 12: All updates go to custom_fields
|
||||
custom_fields = customer.get_custom_fields()
|
||||
|
||||
# Update core fields (now stored in custom_fields)
|
||||
if request.form.get("phone") is not None:
|
||||
custom_fields["phone"] = request.form.get("phone", "").strip()
|
||||
|
||||
if request.form.get("address_street") is not None:
|
||||
custom_fields["address_street"] = request.form.get(
|
||||
"address_street", ""
|
||||
).strip()
|
||||
|
||||
if request.form.get("address_city") is not None:
|
||||
custom_fields["address_city"] = request.form.get("address_city", "").strip()
|
||||
|
||||
if request.form.get("address_zip") is not None:
|
||||
custom_fields["address_zip"] = request.form.get("address_zip", "").strip()
|
||||
|
||||
# Update custom fields based on schema
|
||||
schema_custom_fields = schema.get("custom_fields", [])
|
||||
|
||||
for field in schema_custom_fields:
|
||||
field_name = field.get("name")
|
||||
if not field_name:
|
||||
continue
|
||||
|
||||
form_value = request.form.get(f"custom_{field_name}")
|
||||
if form_value is not None:
|
||||
# Handle different field types
|
||||
field_type = field.get("type", "text")
|
||||
if field_type == "checkbox":
|
||||
custom_fields[field_name] = form_value in {"on", "1"}
|
||||
else:
|
||||
custom_fields[field_name] = form_value.strip()
|
||||
|
||||
customer.set_custom_fields(custom_fields)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Profile updated for customer {customer.id}")
|
||||
flash("Profil erfolgreich aktualisiert.", "success")
|
||||
return redirect(url_for("profile.show"))
|
||||
|
||||
# GET: Show edit form
|
||||
custom_fields = customer.get_custom_fields()
|
||||
|
||||
return render_template(
|
||||
"profile/edit.html",
|
||||
customer=customer,
|
||||
schema=schema,
|
||||
custom_fields=custom_fields,
|
||||
field_config=field_config,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/sync", methods=["POST"])
|
||||
@login_required
|
||||
def sync_from_wordpress():
|
||||
"""Sync custom fields from latest booking.
|
||||
|
||||
Fetches the most recent booking and syncs any custom field values
|
||||
that are missing in the customer profile.
|
||||
"""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
# Get latest booking
|
||||
bookings = WordPressAPI.get_bookings(customer.email)
|
||||
if not bookings:
|
||||
flash("Keine Buchungen zum Synchronisieren gefunden.", "info")
|
||||
return redirect(url_for("profile.show"))
|
||||
|
||||
# Get most recent booking (first in list, sorted by date desc)
|
||||
latest_booking = bookings[0]
|
||||
booking_id = latest_booking.get("id")
|
||||
|
||||
if booking_id:
|
||||
synced = WordPressAPI.sync_customer_fields_from_booking(
|
||||
customer, booking_id
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if synced:
|
||||
synced_names = list(synced.keys())
|
||||
flash(f"Felder synchronisiert: {', '.join(synced_names)}", "success")
|
||||
else:
|
||||
flash("Alle Felder sind bereits aktuell.", "info")
|
||||
else:
|
||||
flash("Buchungs-ID nicht gefunden.", "warning")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed for customer {customer.id}: {e}")
|
||||
flash(
|
||||
"Synchronisation fehlgeschlagen. Bitte versuchen Sie es spaeter.", "error"
|
||||
)
|
||||
|
||||
return redirect(url_for("profile.show"))
|
||||
|
||||
|
||||
@bp.route("/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def settings():
|
||||
"""Manage notification and communication settings."""
|
||||
customer = g.customer
|
||||
db = get_db()
|
||||
|
||||
if request.method == "POST":
|
||||
# Update notification settings
|
||||
customer.email_notifications = request.form.get("email_notifications") == "on"
|
||||
customer.email_reminders = request.form.get("email_reminders") == "on"
|
||||
customer.email_invoices = request.form.get("email_invoices") == "on"
|
||||
customer.email_marketing = request.form.get("email_marketing") == "on"
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Settings updated for customer {customer.id}")
|
||||
flash("Einstellungen erfolgreich gespeichert.", "success")
|
||||
return redirect(url_for("profile.settings"))
|
||||
|
||||
return render_template("profile/settings.html", customer=customer)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user