Initial commit - Customer Portal for Coolify

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

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

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

View File

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

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

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

View File

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

View File

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