Files
customer-portal/customer_portal/models/customer.py

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