319 lines
9.9 KiB
Python
Executable File
319 lines
9.9 KiB
Python
Executable File
"""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
|