"""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"" @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