Files
orion/app/modules/tenancy/models/merchant.py
Samir Boulahtit 9bceeaac9c feat(arch): implement soft delete for business-critical models
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>
2026-03-28 21:08:07 +01:00

125 lines
4.5 KiB
Python

# app/modules/tenancy/models/merchant.py
"""
Merchant model representing the business entity that owns one or more store brands.
A Merchant represents the legal/business entity with contact information,
while Stores represent the individual brands/storefronts operated by that merchant.
"""
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import SoftDeleteMixin, TimestampMixin
class Merchant(Base, TimestampMixin, SoftDeleteMixin):
"""
Represents a merchant (business entity) in the system.
A merchant owns one or more store brands. All business/contact information
is stored at the merchant level to avoid duplication.
"""
__tablename__ = "merchants"
# ========================================================================
# Basic Information
# ========================================================================
id = Column(Integer, primary_key=True, index=True)
"""Unique identifier for the merchant."""
name = Column(String, nullable=False, index=True)
"""Merchant legal/business name."""
description = Column(Text)
"""Optional description of the merchant."""
# ========================================================================
# Ownership
# ========================================================================
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
"""Foreign key to the user who owns this merchant."""
# ========================================================================
# Contact Information
# ========================================================================
contact_email = Column(String, nullable=False)
"""Primary business contact email."""
contact_phone = Column(String)
"""Business phone number."""
website = Column(String)
"""Merchant website URL."""
# ========================================================================
# Business Details
# ========================================================================
business_address = Column(Text)
"""Physical business address."""
tax_number = Column(String)
"""Tax/VAT registration number."""
# ========================================================================
# Status Flags
# ========================================================================
is_active = Column(Boolean, default=True, nullable=False)
"""Whether the merchant is active. Affects all associated stores."""
is_verified = Column(Boolean, default=False, nullable=False)
"""Whether the merchant has been verified by platform admins."""
# ========================================================================
# Relationships
# ========================================================================
owner = relationship("User", foreign_keys="[Merchant.owner_user_id]", back_populates="owned_merchants")
"""The user who owns this merchant."""
stores = relationship(
"Store",
back_populates="merchant",
cascade="all, delete-orphan",
order_by="Store.name",
)
"""All store brands operated by this merchant."""
merchant_domains = relationship(
"MerchantDomain",
back_populates="merchant",
cascade="all, delete-orphan",
)
"""Custom domains registered at the merchant level (inherited by all stores)."""
def __repr__(self):
"""String representation of the Merchant object."""
return f"<Merchant(id={self.id}, name='{self.name}', stores={len(self.stores) if self.stores else 0})>"
# ========================================================================
# Helper Properties
# ========================================================================
@property
def store_count(self) -> int:
"""Get the number of stores belonging to this merchant."""
return len(self.stores) if self.stores else 0
@property
def primary_domain(self) -> str | None:
"""Get the primary active and verified merchant domain."""
for md in self.merchant_domains:
if md.is_primary and md.is_active and md.is_verified:
return md.domain
return None
@property
def active_store_count(self) -> int:
"""Get the number of active stores belonging to this merchant."""
if not self.stores:
return 0
return sum(1 for v in self.stores if v.is_active)
__all__ = ["Merchant"]