Initial commit - Customer Portal for Coolify
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user