Initial commit - Customer Portal for Coolify
This commit is contained in:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.eggs
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile*
|
||||||
|
!Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests/
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
|
conftest.py
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
.gitlab-ci.yml
|
||||||
|
.travis.yml
|
||||||
|
Jenkinsfile
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
migrations/versions/*.pyc
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
81
Dockerfile
Normal file
81
Dockerfile
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Customer Portal - Bulletproof Production Dockerfile
|
||||||
|
# Security-hardened, multi-stage build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 1: Build dependencies
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.13-slim-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install build-time dependencies (not in final image)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create virtual environment for clean dependency isolation
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --no-warn-script-location -r requirements.txt
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 2: Production image
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
# OCI Labels
|
||||||
|
LABEL org.opencontainers.image.title="Customer Portal"
|
||||||
|
LABEL org.opencontainers.image.description="Customer portal for Kurs-Booking"
|
||||||
|
LABEL org.opencontainers.image.vendor="webideas24"
|
||||||
|
LABEL org.opencontainers.image.version="1.0.0"
|
||||||
|
LABEL org.opencontainers.image.source="https://git.islandpferde-melanieworbs.de/webideas24/customer-portal"
|
||||||
|
|
||||||
|
# Security: Install tini and minimal runtime dependencies only
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tini \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
postgresql-client \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||||
|
&& rm -rf /root/.cache
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
# Security: Create non-root user with no shell
|
||||||
|
RUN groupadd -r -g 1000 portal && \
|
||||||
|
useradd -r -u 1000 -g portal -s /usr/sbin/nologin -d /home/portal portal && \
|
||||||
|
mkdir -p /home/portal && \
|
||||||
|
chown -R portal:portal /app /home/portal
|
||||||
|
|
||||||
|
# Copy application and entrypoint
|
||||||
|
COPY --chown=portal:portal customer_portal/ customer_portal/
|
||||||
|
COPY --chown=portal:portal entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod 755 /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER portal
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
FLASK_ENV=production \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Use tini as init, entrypoint handles migrations
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
|
||||||
68
Dockerfile.production
Executable file
68
Dockerfile.production
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
# Production Dockerfile for Customer Portal
|
||||||
|
# Multi-stage build for smaller image size
|
||||||
|
|
||||||
|
# Stage 1: Build dependencies
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Production image
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies only
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Copy application code (includes migrations in customer_portal/migrations/)
|
||||||
|
COPY customer_portal/ customer_portal/
|
||||||
|
|
||||||
|
# Copy and prepare entrypoint
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Create non-root user and set permissions
|
||||||
|
RUN useradd -m -r portal && \
|
||||||
|
chown -R portal:portal /app && \
|
||||||
|
chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
USER portal
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Entrypoint handles:
|
||||||
|
# 1. Wait for PostgreSQL
|
||||||
|
# 2. Run flask db upgrade
|
||||||
|
# 3. Start Gunicorn
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
3
customer_portal/__init__.py
Executable file
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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user