Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.
Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.
Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain
Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade
Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores
Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
561 lines
21 KiB
Python
561 lines
21 KiB
Python
# app/modules/tenancy/models/store.py
|
|
"""
|
|
Store model representing entities that sell products or services.
|
|
|
|
This module defines the Store model along with its relationships to
|
|
other models such as User (owner), Product, Customer, and Order.
|
|
|
|
Note: MarketplaceImportJob relationships are owned by the marketplace module.
|
|
"""
|
|
|
|
from sqlalchemy import (
|
|
JSON,
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
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 SoftDeleteMixin, TimestampMixin
|
|
|
|
|
|
class Store(Base, TimestampMixin, SoftDeleteMixin):
|
|
"""Represents a store in the system."""
|
|
|
|
__tablename__ = "stores" # Name of the table in the database
|
|
__table_args__ = (
|
|
Index("uq_stores_store_code_active", "store_code", unique=True, postgresql_where="deleted_at IS NULL"),
|
|
Index("uq_stores_subdomain_active", "subdomain", unique=True, postgresql_where="deleted_at IS NULL"),
|
|
)
|
|
|
|
id = Column(
|
|
Integer, primary_key=True, index=True
|
|
) # Primary key and indexed column for store ID
|
|
|
|
# Merchant relationship
|
|
merchant_id = Column(
|
|
Integer, ForeignKey("merchants.id"), nullable=False, index=True
|
|
) # Foreign key to the parent merchant
|
|
|
|
store_code = Column(
|
|
String, index=True, nullable=False
|
|
) # Indexed, non-nullable store code column (unique among non-deleted)
|
|
subdomain = Column(
|
|
String(100), nullable=False, index=True
|
|
) # Non-nullable subdomain column (unique among non-deleted)
|
|
name = Column(
|
|
String, nullable=False
|
|
) # Non-nullable name column for the store (brand name)
|
|
description = Column(Text) # Optional text description column for the store
|
|
|
|
# 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 Store Identity (for linking to Letzshop marketplace profile)
|
|
letzshop_store_id = Column(
|
|
String(100), unique=True, nullable=True, index=True
|
|
) # Letzshop's store identifier
|
|
letzshop_store_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 (store-specific, can differ from merchant status)
|
|
is_active = Column(
|
|
Boolean, default=True
|
|
) # Boolean to indicate if the store brand is active
|
|
is_verified = Column(
|
|
Boolean, default=False
|
|
) # Boolean to indicate if the store brand is verified
|
|
|
|
# ========================================================================
|
|
# Contact Information (nullable = inherit from merchant)
|
|
# ========================================================================
|
|
# These fields allow store-specific branding/identity.
|
|
# If null, the value is inherited from the parent merchant.
|
|
contact_email = Column(String(255), nullable=True) # Override merchant contact email
|
|
contact_phone = Column(String(50), nullable=True) # Override merchant contact phone
|
|
website = Column(String(255), nullable=True) # Override merchant website
|
|
business_address = Column(Text, nullable=True) # Override merchant business address
|
|
tax_number = Column(String(100), nullable=True) # Override merchant tax number
|
|
|
|
# ========================================================================
|
|
# Language Settings
|
|
# ========================================================================
|
|
# Supported languages: en, fr, de, lb (Luxembourgish)
|
|
default_language = Column(
|
|
String(5), nullable=False, default="fr"
|
|
) # Default language for store content (products, emails, etc.)
|
|
dashboard_language = Column(
|
|
String(5), nullable=False, default="fr"
|
|
) # Language for store 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", "lb"]
|
|
) # Array of enabled languages for storefront language selector
|
|
|
|
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 EUR", 'en-GB' = "EUR29.99")
|
|
# NULL means inherit from platform default (AdminSetting 'default_storefront_locale')
|
|
storefront_locale = Column(String(10), nullable=True)
|
|
|
|
# ========================================================================
|
|
# Relationships
|
|
# ========================================================================
|
|
merchant = relationship(
|
|
"Merchant", back_populates="stores"
|
|
) # Relationship with Merchant model for the parent merchant
|
|
store_users = relationship(
|
|
"StoreUser", back_populates="store"
|
|
) # Relationship with StoreUser model for users in this store
|
|
products = relationship(
|
|
"Product", back_populates="store"
|
|
) # Relationship with Product model for products of this store
|
|
customers = relationship(
|
|
"Customer", back_populates="store"
|
|
) # Relationship with Customer model for customers of this store
|
|
orders = relationship(
|
|
"Order", back_populates="store"
|
|
) # Relationship with Order model for orders placed by this store
|
|
# NOTE: marketplace_import_jobs relationship removed - owned by marketplace module
|
|
# Use: MarketplaceImportJob.query.filter_by(store_id=store.id) instead
|
|
|
|
# Letzshop integration credentials (one-to-one)
|
|
letzshop_credentials = relationship(
|
|
"StoreLetzshopCredentials",
|
|
back_populates="store",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Invoice settings (one-to-one)
|
|
invoice_settings = relationship(
|
|
"StoreInvoiceSettings",
|
|
back_populates="store",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Invoices (one-to-many)
|
|
invoices = relationship(
|
|
"Invoice",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Email template overrides (one-to-many)
|
|
email_templates = relationship(
|
|
"StoreEmailTemplate",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Email settings (one-to-one) - store SMTP/provider configuration
|
|
email_settings = relationship(
|
|
"StoreEmailSettings",
|
|
back_populates="store",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Add-ons purchased by store (one-to-many)
|
|
addons = relationship(
|
|
"StoreAddOn",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Billing/invoice history (one-to-many)
|
|
billing_history = relationship(
|
|
"BillingHistory",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
order_by="BillingHistory.invoice_date.desc()",
|
|
)
|
|
|
|
domains = relationship(
|
|
"StoreDomain",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
order_by="StoreDomain.is_primary.desc()",
|
|
) # Relationship with StoreDomain model for custom domains of the store
|
|
|
|
# Single theme relationship (ONE store = ONE theme)
|
|
# A store has ONE active theme stored in the store_themes table.
|
|
# Theme presets available: default, modern, classic, minimal, vibrant
|
|
store_theme = relationship(
|
|
"StoreTheme",
|
|
back_populates="store",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
) # Relationship with StoreTheme model for the active theme of the store
|
|
|
|
# Content pages relationship (store can override platform default pages)
|
|
content_pages = relationship(
|
|
"ContentPage", back_populates="store", cascade="all, delete-orphan"
|
|
) # Relationship with ContentPage model for store-specific content pages
|
|
|
|
# Onboarding progress (one-to-one)
|
|
onboarding = relationship(
|
|
"StoreOnboarding",
|
|
back_populates="store",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Media library (one-to-many)
|
|
media_files = relationship(
|
|
"MediaFile",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
# Platform memberships (many-to-many via junction table)
|
|
store_platforms = relationship(
|
|
"StorePlatform",
|
|
back_populates="store",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
def __repr__(self):
|
|
"""String representation of the Store object."""
|
|
return f"<Store(id={self.id}, store_code='{self.store_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 store.
|
|
|
|
Returns theme from store_themes table, or default theme if not set.
|
|
|
|
Returns:
|
|
dict: Theme configuration with colors, fonts, layout, etc.
|
|
"""
|
|
# Check store_themes table
|
|
if self.store_theme and self.store_theme.is_active:
|
|
return self.store_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 store."""
|
|
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 effective_domain(self) -> str | None:
|
|
"""
|
|
Get effective domain: store override > merchant domain > subdomain fallback.
|
|
|
|
Domain Resolution Priority:
|
|
1. Store-specific custom domain (StoreDomain) -> highest priority
|
|
2. Merchant domain (MerchantDomain) -> inherited default
|
|
3. Store subdomain ({store.subdomain}.{main_domain}) -> fallback
|
|
"""
|
|
if self.primary_domain:
|
|
return self.primary_domain
|
|
if self.merchant and self.merchant.primary_domain:
|
|
return self.merchant.primary_domain
|
|
return f"{self.subdomain}.{settings.main_domain}"
|
|
|
|
@property
|
|
def all_domains(self):
|
|
"""Get all active domains (subdomain + custom domains)."""
|
|
domains = [
|
|
f"{self.subdomain}.{settings.main_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 (store override or merchant fallback)
|
|
|
|
@property
|
|
def effective_contact_email(self) -> str | None:
|
|
"""Get contact email (store override or merchant fallback)."""
|
|
if self.contact_email is not None:
|
|
return self.contact_email
|
|
return self.merchant.contact_email if self.merchant else None
|
|
|
|
@property
|
|
def effective_contact_phone(self) -> str | None:
|
|
"""Get contact phone (store override or merchant fallback)."""
|
|
if self.contact_phone is not None:
|
|
return self.contact_phone
|
|
return self.merchant.contact_phone if self.merchant else None
|
|
|
|
@property
|
|
def effective_website(self) -> str | None:
|
|
"""Get website (store override or merchant fallback)."""
|
|
if self.website is not None:
|
|
return self.website
|
|
return self.merchant.website if self.merchant else None
|
|
|
|
@property
|
|
def effective_business_address(self) -> str | None:
|
|
"""Get business address (store override or merchant fallback)."""
|
|
if self.business_address is not None:
|
|
return self.business_address
|
|
return self.merchant.business_address if self.merchant else None
|
|
|
|
@property
|
|
def effective_tax_number(self) -> str | None:
|
|
"""Get tax number (store override or merchant fallback)."""
|
|
if self.tax_number is not None:
|
|
return self.tax_number
|
|
return self.merchant.tax_number if self.merchant 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 merchant.
|
|
"""
|
|
merchant = self.merchant
|
|
return {
|
|
"contact_email": self.effective_contact_email,
|
|
"contact_email_inherited": self.contact_email is None
|
|
and merchant is not None,
|
|
"contact_phone": self.effective_contact_phone,
|
|
"contact_phone_inherited": self.contact_phone is None
|
|
and merchant is not None,
|
|
"website": self.effective_website,
|
|
"website_inherited": self.website is None and merchant is not None,
|
|
"business_address": self.effective_business_address,
|
|
"business_address_inherited": self.business_address is None
|
|
and merchant is not None,
|
|
"tax_number": self.effective_tax_number,
|
|
"tax_number_inherited": self.tax_number is None and merchant is not None,
|
|
}
|
|
|
|
|
|
class StoreUser(Base, TimestampMixin, SoftDeleteMixin):
|
|
"""
|
|
Represents a user's team membership in a store.
|
|
|
|
Ownership is determined via User.is_owner_of(store_id) which checks
|
|
Merchant.owner_user_id, NOT a field on this table.
|
|
|
|
This table is for team members only (invited by owner).
|
|
"""
|
|
|
|
__tablename__ = "store_users"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
"""Unique identifier for each StoreUser entry."""
|
|
|
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
|
"""Foreign key linking to the associated Store."""
|
|
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
"""Foreign key linking to the associated User."""
|
|
|
|
# Role for team members (determines granular 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 StoreUser."""
|
|
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 StoreUser role is active."""
|
|
|
|
# Relationships
|
|
store = relationship("Store", back_populates="store_users")
|
|
"""Relationship to the Store model, representing the associated store."""
|
|
|
|
user = relationship(
|
|
"User", foreign_keys=[user_id], back_populates="store_memberships"
|
|
)
|
|
"""Relationship to the User model, representing the user who holds this role within the store."""
|
|
|
|
inviter = relationship("User", foreign_keys=[invited_by])
|
|
"""Optional relationship to the User model, representing the user who invited this StoreUser."""
|
|
|
|
role = relationship("Role", back_populates="store_users")
|
|
"""Relationship to the Role model, representing the role held by the store user."""
|
|
|
|
def __repr__(self) -> str:
|
|
"""Return a string representation of the StoreUser instance."""
|
|
role_name = self.role.name if self.role else "no-role"
|
|
return f"<StoreUser(store_id={self.store_id}, user_id={self.user_id}, role={role_name})>"
|
|
|
|
@property
|
|
def is_owner(self) -> bool:
|
|
"""Check if this user is the owner of the store (via merchant ownership)."""
|
|
return self.user.is_owner_of(self.store_id)
|
|
|
|
@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 (merchant 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:
|
|
from app.modules.tenancy.services.permission_discovery_service import (
|
|
permission_discovery_service,
|
|
)
|
|
|
|
return list(permission_discovery_service.get_all_permission_ids())
|
|
|
|
if self.role and self.role.permissions:
|
|
return self.role.permissions
|
|
|
|
return []
|
|
|
|
|
|
class Role(Base, TimestampMixin):
|
|
"""Represents a role within a store'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."""
|
|
|
|
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
|
"""Foreign key linking to the associated Store."""
|
|
|
|
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."""
|
|
|
|
store = relationship("Store")
|
|
"""Relationship to the Store model, representing the associated store."""
|
|
|
|
store_users = relationship("StoreUser", back_populates="role")
|
|
"""Back-relationship to the StoreUser 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}', store_id={self.store_id})>"
|
|
|
|
|
|
__all__ = ["Store", "StoreUser", "Role"]
|