Some checks failed
Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean) into a single 4-value UserRole enum: super_admin, platform_admin, merchant_owner, store_member. Drop stale StoreUser.user_type column. Fix role="user" bug in merchant creation. Key changes: - Expand UserRole enum from 2 to 4 values with computed properties (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user) - Add Alembic migration (tenancy_003) for data migration + column drops - Remove is_super_admin from JWT token payload - Update all auth dependencies, services, routes, templates, JS, and tests - Update all RBAC documentation 66 files changed, 1219 unit tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
7.6 KiB
Python
210 lines
7.6 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, Integer, String
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.core.database import Base
|
|
from models.database.base import 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):
|
|
"""Represents a platform user (admins, merchant owners, and store team)."""
|
|
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
email = Column(String, unique=True, index=True, nullable=False)
|
|
username = Column(String, unique=True, 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", 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"]
|