Files
orion/models/database/vendor.py
Samir Boulahtit b5a803cde8 feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with:

Database Models (5 tables):
- loyalty_programs: Vendor program configuration
- loyalty_cards: Customer cards with stamp/point balances
- loyalty_transactions: Immutable audit log
- staff_pins: Fraud prevention PINs (bcrypt hashed)
- apple_device_registrations: Apple Wallet push tokens

Services:
- program_service: Program CRUD and statistics
- card_service: Customer enrollment and card lookup
- stamp_service: Stamp operations with anti-fraud checks
- points_service: Points earning and redemption
- pin_service: Staff PIN management with lockout
- wallet_service: Unified wallet abstraction
- google_wallet_service: Google Wallet API integration
- apple_wallet_service: Apple Wallet .pkpass generation

API Routes:
- Admin: /api/v1/admin/loyalty/* (programs list, stats)
- Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs)
- Public: /api/v1/loyalty/* (enrollment, Apple Web Service)

Anti-Fraud Features:
- Staff PIN verification (configurable per program)
- Cooldown period between stamps (default 15 min)
- Daily stamp limits (default 5/day)
- PIN lockout after failed attempts

Wallet Integration:
- Google Wallet: LoyaltyClass and LoyaltyObject management
- Apple Wallet: .pkpass generation with PKCS#7 signing
- Apple Web Service endpoints for device registration/updates

Also includes:
- Alembic migration for all tables with indexes
- Localization files (en, fr, de, lu)
- Module documentation
- Phase 2 interface and user journey plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:04:00 +01:00

569 lines
21 KiB
Python

# models/database/vendor.py
"""
Vendor model representing entities that sell products or services.
This module defines the Vendor model along with its relationships to
other models such as User (owner), Product, Customer, Order, and MarketplaceImportJob.
"""
import enum
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.config import settings
# Import Base from the central database module instead of creating a new one
from app.core.database import Base
from models.database.base import TimestampMixin
class Vendor(Base, TimestampMixin):
"""Represents a vendor in the system."""
__tablename__ = "vendors" # Name of the table in the database
id = Column(
Integer, primary_key=True, index=True
) # Primary key and indexed column for vendor ID
# Company relationship
company_id = Column(
Integer, ForeignKey("companies.id"), nullable=False, index=True
) # Foreign key to the parent company
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # Unique, indexed, non-nullable vendor code column
subdomain = Column(
String(100), unique=True, nullable=False, index=True
) # Unique, non-nullable subdomain column with indexing
name = Column(
String, nullable=False
) # Non-nullable name column for the vendor (brand name)
description = Column(Text) # Optional text description column for the vendor
# Letzshop URLs - multi-language support (brand-specific marketplace feeds)
letzshop_csv_url_fr = Column(String) # URL for French CSV in Letzshop
letzshop_csv_url_en = Column(String) # URL for English CSV in Letzshop
letzshop_csv_url_de = Column(String) # URL for German CSV in Letzshop
# Letzshop Vendor Identity (for linking to Letzshop marketplace profile)
letzshop_vendor_id = Column(
String(100), unique=True, nullable=True, index=True
) # Letzshop's vendor identifier
letzshop_vendor_slug = Column(
String(200), nullable=True, index=True
) # Letzshop shop URL slug (e.g., "my-shop" from letzshop.lu/vendors/my-shop)
# ========================================================================
# Letzshop Feed Settings (atalanda namespace)
# ========================================================================
# These are default values applied to all products in the Letzshop feed
# See https://letzshop.lu/en/dev#google_csv for documentation
# Default VAT rate for new products: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard)
letzshop_default_tax_rate = Column(Integer, default=17, nullable=False)
# Product sort priority on Letzshop (0.0-10.0, higher = displayed first)
# Note: Having all products rated above 7 is not permitted by Letzshop
letzshop_boost_sort = Column(String(10), default="5.0") # Stored as string for precision
# Delivery method: 'nationwide', 'package_delivery', 'self_collect' (comma-separated for multiple)
# 'nationwide' automatically includes package_delivery and self_collect
letzshop_delivery_method = Column(String(100), default="package_delivery")
# Pre-order days: number of days before item ships (default 1 day)
letzshop_preorder_days = Column(Integer, default=1)
# Status (vendor-specific, can differ from company status)
is_active = Column(
Boolean, default=True
) # Boolean to indicate if the vendor brand is active
is_verified = Column(
Boolean, default=False
) # Boolean to indicate if the vendor brand is verified
# ========================================================================
# Contact Information (nullable = inherit from company)
# ========================================================================
# These fields allow vendor-specific branding/identity.
# If null, the value is inherited from the parent company.
contact_email = Column(String(255), nullable=True) # Override company contact email
contact_phone = Column(String(50), nullable=True) # Override company contact phone
website = Column(String(255), nullable=True) # Override company website
business_address = Column(Text, nullable=True) # Override company business address
tax_number = Column(String(100), nullable=True) # Override company tax number
# ========================================================================
# Language Settings
# ========================================================================
# Supported languages: en, fr, de, lb (Luxembourgish)
default_language = Column(
String(5), nullable=False, default="fr"
) # Default language for vendor content (products, emails, etc.)
dashboard_language = Column(
String(5), nullable=False, default="fr"
) # Language for vendor team dashboard UI
storefront_language = Column(
String(5), nullable=False, default="fr"
) # Default language for customer-facing storefront
storefront_languages = Column(
JSON, nullable=False, default=["fr", "de", "en"]
) # Array of enabled languages for storefront language selector
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 €", 'en-GB' = "€29.99")
# NULL means inherit from platform default (AdminSetting 'default_storefront_locale')
storefront_locale = Column(String(10), nullable=True)
# ========================================================================
# Relationships
# ========================================================================
company = relationship(
"Company", back_populates="vendors"
) # Relationship with Company model for the parent company
vendor_users = relationship(
"VendorUser", back_populates="vendor"
) # Relationship with VendorUser model for users in this vendor
products = relationship(
"Product", back_populates="vendor"
) # Relationship with Product model for products of this vendor
customers = relationship(
"Customer", back_populates="vendor"
) # Relationship with Customer model for customers of this vendor
orders = relationship(
"Order", back_populates="vendor"
) # Relationship with Order model for orders placed by this vendor
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="vendor"
) # Relationship with MarketplaceImportJob model for import jobs related to this vendor
# Letzshop integration credentials (one-to-one)
letzshop_credentials = relationship(
"VendorLetzshopCredentials",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Invoice settings (one-to-one)
invoice_settings = relationship(
"VendorInvoiceSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Invoices (one-to-many)
invoices = relationship(
"Invoice",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Email template overrides (one-to-many)
email_templates = relationship(
"VendorEmailTemplate",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Email settings (one-to-one) - vendor SMTP/provider configuration
email_settings = relationship(
"VendorEmailSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Subscription (one-to-one)
subscription = relationship(
"VendorSubscription",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Add-ons purchased by vendor (one-to-many)
addons = relationship(
"VendorAddOn",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Billing/invoice history (one-to-many)
billing_history = relationship(
"BillingHistory",
back_populates="vendor",
cascade="all, delete-orphan",
order_by="BillingHistory.invoice_date.desc()",
)
domains = relationship(
"VendorDomain",
back_populates="vendor",
cascade="all, delete-orphan",
order_by="VendorDomain.is_primary.desc()",
) # Relationship with VendorDomain model for custom domains of the vendor
# Single theme relationship (ONE vendor = ONE theme)
# A vendor has ONE active theme stored in the vendor_themes table.
# Theme presets available: default, modern, classic, minimal, vibrant
vendor_theme = relationship(
"VendorTheme",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
) # Relationship with VendorTheme model for the active theme of the vendor
# Content pages relationship (vendor can override platform default pages)
content_pages = relationship(
"ContentPage", back_populates="vendor", cascade="all, delete-orphan"
) # Relationship with ContentPage model for vendor-specific content pages
# Onboarding progress (one-to-one)
onboarding = relationship(
"VendorOnboarding",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Media library (one-to-many)
media_files = relationship(
"MediaFile",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Platform memberships (many-to-many via junction table)
vendor_platforms = relationship(
"VendorPlatform",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Loyalty program (one-to-one)
loyalty_program = relationship(
"LoyaltyProgram",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self):
"""String representation of the Vendor object."""
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
# ========================================================================
# Theme Helper Methods to get active theme and other related information
# ========================================================================
def get_effective_theme(self) -> dict:
"""
Get active theme for this vendor.
Returns theme from vendor_themes table, or default theme if not set.
Returns:
dict: Theme configuration with colors, fonts, layout, etc.
"""
# Check vendor_themes table
if self.vendor_theme and self.vendor_theme.is_active:
return self.vendor_theme.to_dict()
# Return default theme
return self._get_default_theme()
def _get_default_theme(self) -> dict:
"""Return the default theme configuration."""
return {
"theme_name": "default",
"colors": {
"primary": "#6366f1",
"secondary": "#8b5cf6",
"accent": "#ec4899",
"background": "#ffffff",
"text": "#1f2937",
"border": "#e5e7eb",
},
"fonts": {"heading": "Inter, sans-serif", "body": "Inter, sans-serif"},
"branding": {
"logo": None,
"logo_dark": None,
"favicon": None,
"banner": None,
},
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
"social_links": {},
"custom_css": None,
"css_variables": {
"--color-primary": "#6366f1",
"--color-secondary": "#8b5cf6",
"--color-accent": "#ec4899",
"--color-background": "#ffffff",
"--color-text": "#1f2937",
"--color-border": "#e5e7eb",
"--font-heading": "Inter, sans-serif",
"--font-body": "Inter, sans-serif",
},
}
def get_primary_color(self) -> str:
"""Get primary color from active theme."""
theme = self.get_effective_theme()
return theme.get("colors", {}).get(
"primary", "#6366f1"
) # Default to default theme if not found
def get_logo_url(self) -> str:
"""Get logo URL from active theme."""
theme = self.get_effective_theme()
return theme.get("branding", {}).get(
"logo"
) # Return None or the logo URL if found
# ========================================================================
# Domain Helper Methods
# ========================================================================
@property
def primary_domain(self):
"""Get the primary custom domain for this vendor."""
for domain in self.domains:
if domain.is_primary and domain.is_active:
return domain.domain # Return the domain if it's primary and active
return None
@property
def all_domains(self):
"""Get all active domains (subdomain + custom domains)."""
domains = [
f"{self.subdomain}.{settings.platform_domain}"
] # Start with the main subdomain
for domain in self.domains:
if domain.is_active:
domains.append(domain.domain) # Add other active custom domains
return domains
# ========================================================================
# Contact Resolution Helper Properties
# ========================================================================
# These properties return the effective value (vendor override or company fallback)
@property
def effective_contact_email(self) -> str | None:
"""Get contact email (vendor override or company fallback)."""
if self.contact_email is not None:
return self.contact_email
return self.company.contact_email if self.company else None
@property
def effective_contact_phone(self) -> str | None:
"""Get contact phone (vendor override or company fallback)."""
if self.contact_phone is not None:
return self.contact_phone
return self.company.contact_phone if self.company else None
@property
def effective_website(self) -> str | None:
"""Get website (vendor override or company fallback)."""
if self.website is not None:
return self.website
return self.company.website if self.company else None
@property
def effective_business_address(self) -> str | None:
"""Get business address (vendor override or company fallback)."""
if self.business_address is not None:
return self.business_address
return self.company.business_address if self.company else None
@property
def effective_tax_number(self) -> str | None:
"""Get tax number (vendor override or company fallback)."""
if self.tax_number is not None:
return self.tax_number
return self.company.tax_number if self.company else None
def get_contact_info_with_inheritance(self) -> dict:
"""
Get all contact info with inheritance flags.
Returns dict with resolved values and flags indicating if inherited from company.
"""
company = self.company
return {
"contact_email": self.effective_contact_email,
"contact_email_inherited": self.contact_email is None
and company is not None,
"contact_phone": self.effective_contact_phone,
"contact_phone_inherited": self.contact_phone is None
and company is not None,
"website": self.effective_website,
"website_inherited": self.website is None and company is not None,
"business_address": self.effective_business_address,
"business_address_inherited": self.business_address is None
and company is not None,
"tax_number": self.effective_tax_number,
"tax_number_inherited": self.tax_number is None and company is not None,
}
class VendorUserType(str, enum.Enum):
"""Types of vendor users."""
OWNER = "owner" # Vendor owner (full access to vendor area)
TEAM_MEMBER = "member" # Team member (role-based access to vendor area)
class VendorUser(Base, TimestampMixin):
"""
Represents a user's membership in a vendor.
- Owner: Created automatically when vendor is created
- Team Member: Invited by owner via email
"""
__tablename__ = "vendor_users"
id = Column(Integer, primary_key=True, index=True)
"""Unique identifier for each VendorUser entry."""
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
"""Foreign key linking to the associated Vendor."""
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
"""Foreign key linking to the associated User."""
# Distinguish between owner and team member
user_type = Column(String, nullable=False, default=VendorUserType.TEAM_MEMBER.value)
# Role for team members (NULL for owners - they have all permissions)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
"""Foreign key linking to the associated Role."""
invited_by = Column(Integer, ForeignKey("users.id"))
"""Foreign key linking to the user who invited this VendorUser."""
invitation_token = Column(String, nullable=True, index=True) # For email activation
invitation_sent_at = Column(DateTime, nullable=True)
invitation_accepted_at = Column(DateTime, nullable=True)
is_active = Column(
Boolean, default=False, nullable=False
) # False until invitation accepted
"""Indicates whether the VendorUser role is active."""
# Relationships
vendor = relationship("Vendor", back_populates="vendor_users")
"""Relationship to the Vendor model, representing the associated vendor."""
user = relationship(
"User", foreign_keys=[user_id], back_populates="vendor_memberships"
)
"""Relationship to the User model, representing the user who holds this role within the vendor."""
inviter = relationship("User", foreign_keys=[invited_by])
"""Optional relationship to the User model, representing the user who invited this VendorUser."""
role = relationship("Role", back_populates="vendor_users")
"""Relationship to the Role model, representing the role held by the vendor user."""
def __repr__(self) -> str:
"""Return a string representation of the VendorUser instance.
Returns:
str: A string that includes the vendor_id, the user_id and the user_type of the VendorUser instance.
"""
return f"<VendorUser(vendor_id={self.vendor_id}, user_id={self.user_id}, type={self.user_type})>"
@property
def is_owner(self) -> bool:
"""Check if this is an owner membership."""
return self.user_type == VendorUserType.OWNER.value
@property
def is_team_member(self) -> bool:
"""Check if this is a team member (not owner)."""
return self.user_type == VendorUserType.TEAM_MEMBER.value
@property
def is_invitation_pending(self) -> bool:
"""Check if invitation is still pending."""
return self.invitation_token is not None and self.invitation_accepted_at is None
def has_permission(self, permission: str) -> bool:
"""
Check if user has a specific permission.
Owners always have all permissions.
Team members check their role's permissions.
"""
# Owners have all permissions
if self.is_owner:
return True
# Inactive users have no permissions
if not self.is_active:
return False
# Check role permissions
if self.role and self.role.permissions:
return permission in self.role.permissions
return False
def get_all_permissions(self) -> list:
"""Get all permissions this user has."""
if self.is_owner:
# Return all possible permissions
from app.core.permissions import VendorPermissions
return list(VendorPermissions.__members__.values())
if self.role and self.role.permissions:
return self.role.permissions
return []
class Role(Base, TimestampMixin):
"""Represents a role within a vendor's system."""
__tablename__ = "roles" # Name of the table in the database
id = Column(Integer, primary_key=True, index=True)
"""Unique identifier for each Role entry."""
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
"""Foreign key linking to the associated Vendor."""
name = Column(String(100), nullable=False)
"""Name of the role, with a maximum length of 100 characters."""
permissions = Column(JSON, default=list)
"""Permissions assigned to this role, stored as a JSON array."""
vendor = relationship("Vendor")
"""Relationship to the Vendor model, representing the associated vendor."""
vendor_users = relationship("VendorUser", back_populates="role")
"""Back-relationship to the VendorUser model, representing users with this role."""
def __repr__(self) -> str:
"""Return a string representation of the Role instance.
Returns:
str: A string that includes the id and name of the Role instance.
"""
return f"<Role(id={self.id}, name='{self.name}', vendor_id={self.vendor_id})>"