Initial commit - Customer Portal for Coolify
This commit is contained in:
45
customer_portal/models/__init__.py
Executable file
45
customer_portal/models/__init__.py
Executable 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)
|
||||
BIN
customer_portal/models/__pycache__/__init__.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/__init__.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/__init__.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/__init__.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/admin_user.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/admin_user.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/admin_user.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/admin_user.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/booking.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/booking.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/customer.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/customer.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/customer.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/customer.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/otp.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/otp.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/session.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/session.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/session.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/session.cpython-312.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/settings.cpython-311.pyc
Executable file
BIN
customer_portal/models/__pycache__/settings.cpython-311.pyc
Executable file
Binary file not shown.
BIN
customer_portal/models/__pycache__/settings.cpython-312.pyc
Executable file
BIN
customer_portal/models/__pycache__/settings.cpython-312.pyc
Executable file
Binary file not shown.
72
customer_portal/models/admin_user.py
Executable file
72
customer_portal/models/admin_user.py
Executable 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
262
customer_portal/models/booking.py
Executable 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)
|
||||
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
|
||||
36
customer_portal/models/otp.py
Executable file
36
customer_portal/models/otp.py
Executable 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
|
||||
36
customer_portal/models/session.py
Executable file
36
customer_portal/models/session.py
Executable 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
|
||||
490
customer_portal/models/settings.py
Executable file
490
customer_portal/models/settings.py
Executable 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)
|
||||
Reference in New Issue
Block a user