Files
orion/app/modules/tenancy/models/user.py
Samir Boulahtit 1dcb0e6c33
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
feat: RBAC Phase 1 — consolidate user roles into 4-value enum
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>
2026-02-19 22:44:29 +01:00

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"]