Files
orion/app/modules/tenancy/models/user.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

214 lines
7.9 KiB
Python

# app/modules/tenancy/models/user.py
"""
User model with authentication support.
ROLE SYSTEM (Phase 1 — Consolidated 4-value enum):
- User.role contains one of 4 values:
* "super_admin" — Platform super administrator (all platforms, all settings)
* "platform_admin" — Platform admin scoped to assigned platforms
* "merchant_owner" — Owns merchant(s) and all their stores
* "store_member" — Team member on specific store(s)
- Store-specific granular permissions are in StoreUser.role_id -> Role.permissions
- Customers are NOT in the User table — they use the Customer model
"""
import enum
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import SoftDeleteMixin, TimestampMixin
class UserRole(str, enum.Enum):
"""Platform-level user roles."""
SUPER_ADMIN = "super_admin" # Platform super administrator
PLATFORM_ADMIN = "platform_admin" # Platform admin (scoped to assigned platforms)
MERCHANT_OWNER = "merchant_owner" # Owns merchant(s) and all their stores
STORE_MEMBER = "store_member" # Team member on specific store(s)
class User(Base, TimestampMixin, SoftDeleteMixin):
"""Represents a platform user (admins, merchant owners, and store team)."""
__tablename__ = "users"
__table_args__ = (
Index("uq_users_email_active", "email", unique=True, postgresql_where="deleted_at IS NULL"),
Index("uq_users_username_active", "username", unique=True, postgresql_where="deleted_at IS NULL"),
)
id = Column(Integer, primary_key=True, index=True)
email = Column(String, index=True, nullable=False)
username = Column(String, index=True, nullable=False)
first_name = Column(String)
last_name = Column(String)
hashed_password = Column(String, nullable=False)
# Consolidated role — one of: super_admin, platform_admin, merchant_owner, store_member
role = Column(String, nullable=False, default=UserRole.STORE_MEMBER.value)
is_active = Column(Boolean, default=True, nullable=False)
is_email_verified = Column(Boolean, default=False, nullable=False)
last_login = Column(DateTime, nullable=True)
# Language preference (NULL = use context default: store dashboard_language or system default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
# Relationships
# NOTE: marketplace_import_jobs relationship removed - owned by marketplace module
# Use: MarketplaceImportJob.query.filter_by(user_id=user.id) instead
owned_merchants = relationship("Merchant", foreign_keys="[Merchant.owner_user_id]", back_populates="owner")
store_memberships = relationship(
"StoreUser", foreign_keys="[StoreUser.user_id]", back_populates="user"
)
# Admin-platform assignments (for platform admins only)
# Super admins don't need assignments - they have access to all platforms
admin_platforms = relationship(
"AdminPlatform",
foreign_keys="AdminPlatform.user_id",
back_populates="user",
cascade="all, delete-orphan",
)
# Menu visibility configuration (for super admins only)
# Platform admins get menu config from their platform, not user-level
menu_configs = relationship(
"AdminMenuConfig",
foreign_keys="AdminMenuConfig.user_id",
back_populates="user",
cascade="all, delete-orphan",
)
def __repr__(self):
"""String representation of the User object."""
return f"<User(id={self.id}, username='{self.username}', email='{self.email}', role='{self.role}')>"
@property
def full_name(self):
"""Returns the full name of the user, combining first and last names if available."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username
# =========================================================================
# Role-checking properties
# =========================================================================
@property
def is_super_admin(self) -> bool:
"""Check if user is a super admin."""
return self.role == UserRole.SUPER_ADMIN.value
@property
def is_admin(self) -> bool:
"""Check if user is any kind of platform admin (super or scoped)."""
return self.role in (UserRole.SUPER_ADMIN.value, UserRole.PLATFORM_ADMIN.value)
@property
def is_platform_admin(self) -> bool:
"""Check if user is a scoped platform admin (not super admin)."""
return self.role == UserRole.PLATFORM_ADMIN.value
@property
def is_merchant_owner(self) -> bool:
"""Check if user is a merchant owner."""
return self.role == UserRole.MERCHANT_OWNER.value
@property
def is_store_user(self) -> bool:
"""Check if user is a merchant owner or store member."""
return self.role in (UserRole.MERCHANT_OWNER.value, UserRole.STORE_MEMBER.value)
def is_owner_of(self, store_id: int) -> bool:
"""
Check if user is the owner of a specific store.
Ownership is determined via merchant ownership:
User owns Merchant -> Merchant has Store -> User owns Store
"""
for merchant in self.owned_merchants:
if any(v.id == store_id for v in merchant.stores):
return True
return False
def is_member_of(self, store_id: int) -> bool:
"""Check if user is a member of a specific store (owner or team)."""
# Check if owner (via merchant)
if self.is_owner_of(store_id):
return True
# Check if team member
return any(
vm.store_id == store_id and vm.is_active for vm in self.store_memberships
)
def get_store_role(self, store_id: int) -> str:
"""Get user's role within a specific store."""
# Check if owner (via merchant)
if self.is_owner_of(store_id):
return "owner"
# Check team membership
for vm in self.store_memberships:
if vm.store_id == store_id and vm.is_active:
return vm.role.name if vm.role else "member"
return None
def has_store_permission(self, store_id: int, permission: str) -> bool:
"""Check if user has a specific permission in a store."""
# Owners have all permissions
if self.is_owner_of(store_id):
return True
# Check team member permissions
for vm in self.store_memberships:
if vm.store_id == store_id and vm.is_active:
if vm.role and permission in vm.role.permissions:
return True
return False
# =========================================================================
# Admin Platform Access Methods
# =========================================================================
def can_access_platform(self, platform_id: int) -> bool:
"""
Check if admin can access a specific platform.
- Super admins can access all platforms
- Platform admins can only access assigned platforms
- Non-admins return False
"""
if not self.is_admin:
return False
if self.is_super_admin:
return True
return any(
ap.platform_id == platform_id and ap.is_active
for ap in self.admin_platforms
)
def get_accessible_platform_ids(self) -> list[int] | None:
"""
Get list of platform IDs this admin can access.
Returns:
- None for super admins (means ALL platforms)
- List of platform IDs for platform admins
- Empty list for non-admins
"""
if not self.is_admin:
return []
if self.is_super_admin:
return None # None means ALL platforms
return [ap.platform_id for ap in self.admin_platforms if ap.is_active]
__all__ = ["User", "UserRole"]