feat: RBAC Phase 1 — consolidate user roles into 4-value enum
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

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>
This commit is contained in:
2026-02-19 22:44:29 +01:00
parent ef21d47533
commit 1dcb0e6c33
67 changed files with 874 additions and 616 deletions

View File

@@ -420,7 +420,7 @@ class StatsService:
total_users = db.query(User).count()
active_users = db.query(User).filter(User.is_active == True).count()
inactive_users = total_users - active_users
admin_users = db.query(User).filter(User.role == "admin").count()
admin_users = db.query(User).filter(User.role.in_(["super_admin", "platform_admin"])).count()
return {
"total_users": total_users,

View File

@@ -80,7 +80,7 @@ def rt_merchant(db, rt_platform):
email=f"merchant_{uuid.uuid4().hex[:8]}@test.com",
username=f"merchant_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="store",
role="merchant_owner",
is_active=True,
)
db.add(owner)

View File

@@ -43,7 +43,7 @@ def merch_owner(db):
email=f"merchowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"merchowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("merchpass123"),
role="store",
role="merchant_owner",
is_active=True,
)
db.add(user)
@@ -160,7 +160,7 @@ def merch_auth_headers(merch_owner, merch_merchant):
id=merch_owner.id,
email=merch_owner.email,
username=merch_owner.username,
role="store",
role="merchant_owner",
is_active=True,
)

View File

@@ -24,7 +24,7 @@ from app.modules.billing.models import (
SubscriptionTier,
)
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store import StoreUser, StoreUserType
from app.modules.tenancy.models.store import StoreUser
from app.modules.tenancy.models.store_platform import StorePlatform
# ============================================================================
@@ -65,7 +65,7 @@ def store_full_setup(db, store_platform):
email=f"storeowner_{uid}@test.com",
username=f"storeowner_{uid}",
hashed_password=auth.hash_password("storepass123"),
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
@@ -98,11 +98,10 @@ def store_full_setup(db, store_platform):
db.commit()
db.refresh(store)
# 4. Create StoreUser (owner association)
# 4. Create StoreUser (ownership determined via Merchant.owner_user_id)
store_user = StoreUser(
store_id=store.id,
user_id=owner.id,
user_type=StoreUserType.OWNER.value,
is_active=True,
)
db.add(store_user)

View File

@@ -175,7 +175,7 @@ function data() {
adminProfile: getAdminProfileFromStorage(),
get isSuperAdmin() {
return this.adminProfile?.is_super_admin === true;
return this.adminProfile?.role === 'super_admin';
},
// ─────────────────────────────────────────────────────────────────
@@ -398,4 +398,4 @@ const PlatformSettings = {
};
// Export to window
window.PlatformSettings = PlatformSettings;
window.PlatformSettings = PlatformSettings;

View File

@@ -147,7 +147,7 @@ function adminLogin() {
throw new Error('Invalid response from server - no token');
}
if (response.user && response.user.role !== 'admin') {
if (response.user && !['super_admin', 'platform_admin'].includes(response.user.role)) {
loginLog.error('Authorization failed: User is not admin', {
actualRole: response.user.role
});
@@ -166,8 +166,7 @@ function adminLogin() {
loginLog.debug('User data stored:', {
username: response.user.username,
role: response.user.role,
id: response.user.id,
is_super_admin: response.user.is_super_admin
id: response.user.id
});
}
@@ -243,4 +242,4 @@ function adminLogin() {
}
}
loginLog.info('Login module loaded');
loginLog.info('Login module loaded');

View File

@@ -87,7 +87,7 @@ class MarketplaceImportJobService:
raise ImportJobNotFoundException(job_id)
# Users can only see their own jobs, admins can see all
if user.role != "admin" and job.user_id != user.id:
if not user.is_admin and job.user_id != user.id:
raise ImportJobNotOwnedException(job_id, user.id)
return job
@@ -160,7 +160,7 @@ class MarketplaceImportJobService:
)
# Users can only see their own jobs, admins can see all store jobs
if user.role != "admin":
if not user.is_admin:
query = query.filter(MarketplaceImportJob.user_id == user.id)
# Apply marketplace filter

View File

@@ -38,8 +38,6 @@ from app.modules.tenancy.models import (
Platform,
Store,
StorePlatform,
StoreUser,
StoreUserType,
User,
)
from middleware.auth import AuthManager
@@ -338,7 +336,7 @@ class PlatformSignupService:
hashed_password=self.auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role="store",
role="merchant_owner",
is_active=True,
)
db.add(user)
@@ -373,14 +371,7 @@ class PlatformSignupService:
db.add(store)
db.flush()
# Create StoreUser (owner)
store_user = StoreUser(
store_id=store.id,
user_id=user.id,
user_type=StoreUserType.OWNER.value,
is_active=True,
)
db.add(store_user)
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
# Create StoreOnboarding record
onboarding_service = OnboardingService(db)

View File

@@ -42,8 +42,8 @@ class ConversationType(str, enum.Enum):
class ParticipantType(str, enum.Enum):
"""Type of participant in a conversation."""
ADMIN = "admin" # User with role="admin"
STORE = "store" # User with role="store" (via StoreUser)
ADMIN = "admin" # Platform admin user (super_admin or platform_admin)
STORE = "store" # Store team user (merchant_owner or store_member)
CUSTOMER = "customer" # Customer model

View File

@@ -0,0 +1,134 @@
"""tenancy: RBAC cleanup — consolidate user roles and drop legacy columns
Migrates users.role from old 3-value scheme (admin/store/user) to new
4-value scheme (super_admin/platform_admin/merchant_owner/store_member).
Drops the now-redundant users.is_super_admin and store_users.user_type columns.
Revision ID: tenancy_003
Revises: tenancy_002
Create Date: 2026-02-19
"""
import sqlalchemy as sa
from alembic import op
revision = "tenancy_003"
down_revision = "tenancy_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ------------------------------------------------------------------
# Step 1: Data migration — remap role values using is_super_admin
# and merchant ownership to determine the new role.
#
# Order matters: the 'admin' rows must be split before we touch
# 'store' or 'user' rows, and 'merchant_owner' for role='user'
# (bug fix) must come before the catch-all 'store_member' update.
# ------------------------------------------------------------------
# admin + is_super_admin=true -> super_admin
op.execute(
"UPDATE users SET role = 'super_admin' "
"WHERE role = 'admin' AND is_super_admin = true"
)
# admin + is_super_admin=false -> platform_admin
op.execute(
"UPDATE users SET role = 'platform_admin' "
"WHERE role = 'admin' AND is_super_admin = false"
)
# store users who own a merchant -> merchant_owner
op.execute(
"UPDATE users SET role = 'merchant_owner' "
"WHERE role = 'store' "
"AND id IN (SELECT owner_user_id FROM merchants)"
)
# legacy 'user' role (bug — should have been 'store') -> merchant_owner
op.execute(
"UPDATE users SET role = 'merchant_owner' "
"WHERE role = 'user'"
)
# remaining store users (not merchant owners) -> store_member
op.execute(
"UPDATE users SET role = 'store_member' "
"WHERE role = 'store' "
"AND id NOT IN (SELECT owner_user_id FROM merchants)"
)
# ------------------------------------------------------------------
# Step 2: Drop legacy columns that are now redundant
# ------------------------------------------------------------------
# users.is_super_admin is replaced by role = 'super_admin'
op.drop_column("users", "is_super_admin")
# store_users.user_type is replaced by users.role (merchant_owner vs store_member)
op.drop_column("store_users", "user_type")
def downgrade() -> None:
# ------------------------------------------------------------------
# Step 1: Re-add dropped columns
# ------------------------------------------------------------------
op.add_column(
"users",
sa.Column(
"is_super_admin",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"store_users",
sa.Column(
"user_type",
sa.String(),
nullable=False,
server_default="member",
),
)
# ------------------------------------------------------------------
# Step 2: Reverse data migration — map new roles back to old scheme
# ------------------------------------------------------------------
# Mark super admins
op.execute(
"UPDATE users SET is_super_admin = true "
"WHERE role = 'super_admin'"
)
# super_admin + platform_admin -> admin
op.execute(
"UPDATE users SET role = 'admin' "
"WHERE role IN ('super_admin', 'platform_admin')"
)
# merchant_owner + store_member -> store
op.execute(
"UPDATE users SET role = 'store' "
"WHERE role IN ('merchant_owner', 'store_member')"
)
# ------------------------------------------------------------------
# Step 3: Restore store_users.user_type based on merchant ownership
#
# If the user owns the merchant that owns the store, they are
# an 'owner'; otherwise 'member'.
# ------------------------------------------------------------------
op.execute(
"UPDATE store_users SET user_type = 'owner' "
"WHERE user_id IN ("
" SELECT m.owner_user_id FROM merchants m"
" JOIN stores s ON s.merchant_id = m.id"
" WHERE s.id = store_users.store_id"
")"
)

View File

@@ -33,7 +33,7 @@ from app.modules.tenancy.models.merchant import Merchant
from app.modules.tenancy.models.merchant_domain import MerchantDomain
from app.modules.tenancy.models.platform import Platform
from app.modules.tenancy.models.platform_module import PlatformModule
from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserType
from app.modules.tenancy.models.store import Role, Store, StoreUser
from app.modules.tenancy.models.store_domain import StoreDomain
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.models.user import User, UserRole
@@ -62,7 +62,6 @@ __all__ = [
# Store
"Store",
"StoreUser",
"StoreUserType",
"Role",
# Merchant configuration
"MerchantDomain",

View File

@@ -3,8 +3,8 @@
AdminPlatform junction table for many-to-many relationship between Admin Users and Platforms.
This enables platform-scoped admin access:
- Super Admins: Have is_super_admin=True on User model, bypass this table
- Platform Admins: Assigned to specific platforms via this junction table
- Super Admins: Have role='super_admin' on User model, bypass this table
- Platform Admins: Have role='platform_admin', assigned to specific platforms via this junction table
A platform admin CAN be assigned to multiple platforms (e.g., both OMS and Loyalty).
"""

View File

@@ -8,8 +8,6 @@ other models such as User (owner), Product, Customer, and Order.
Note: MarketplaceImportJob relationships are owned by the marketplace module.
"""
import enum
from sqlalchemy import (
JSON,
Boolean,
@@ -420,19 +418,14 @@ class Store(Base, TimestampMixin):
}
class StoreUserType(str, enum.Enum):
"""Types of store users."""
OWNER = "owner" # Store owner (full access to store area)
TEAM_MEMBER = "member" # Team member (role-based access to store area)
class StoreUser(Base, TimestampMixin):
"""
Represents a user's membership in a store.
Represents a user's team membership in a store.
- Owner: Created automatically when store is created
- Team Member: Invited by owner via email
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"
@@ -446,10 +439,7 @@ class StoreUser(Base, TimestampMixin):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
"""Foreign key linking to the associated User."""
# Distinguish between owner and team member
user_type = Column(String, nullable=False, default=StoreUserType.TEAM_MEMBER.value)
# Role for team members (NULL for owners - they have all permissions)
# Role for team members (determines granular permissions)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
"""Foreign key linking to the associated Role."""
@@ -480,22 +470,14 @@ class StoreUser(Base, TimestampMixin):
"""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.
Returns:
str: A string that includes the store_id, the user_id and the user_type of the StoreUser instance.
"""
return f"<StoreUser(store_id={self.store_id}, user_id={self.user_id}, type={self.user_type})>"
"""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 is an owner membership."""
return self.user_type == StoreUserType.OWNER.value
@property
def is_team_member(self) -> bool:
"""Check if this is a team member (not owner)."""
return self.user_type == StoreUserType.TEAM_MEMBER.value
"""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:
@@ -506,7 +488,7 @@ class StoreUser(Base, TimestampMixin):
"""
Check if user has a specific permission.
Owners always have all permissions.
Owners (merchant owners) always have all permissions.
Team members check their role's permissions.
"""
# Owners have all permissions
@@ -526,7 +508,6 @@ class StoreUser(Base, TimestampMixin):
def get_all_permissions(self) -> list:
"""Get all permissions this user has."""
if self.is_owner:
# Return all possible permissions from discovery service
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
@@ -571,4 +552,4 @@ class Role(Base, TimestampMixin):
return f"<Role(id={self.id}, name='{self.name}', store_id={self.store_id})>"
__all__ = ["Store", "StoreUser", "StoreUserType", "Role"]
__all__ = ["Store", "StoreUser", "Role"]

View File

@@ -2,13 +2,15 @@
"""
User model with authentication support.
ROLE CLARIFICATION:
- User.role should ONLY contain platform-level roles:
* "admin" - Platform administrator (full system access)
* "store" - Any user who owns or is part of a store team
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 roles (manager, staff, etc.) are stored in StoreUser.role
- Customers are NOT in the User table - they use the Customer model
- 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
@@ -23,12 +25,14 @@ from models.database.base import TimestampMixin
class UserRole(str, enum.Enum):
"""Platform-level user roles."""
ADMIN = "admin" # Platform administrator
STORE = "store" # Store owner or team member
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 and stores only)."""
"""Represents a platform user (admins, merchant owners, and store team)."""
__tablename__ = "users"
@@ -39,18 +43,13 @@ class User(Base, TimestampMixin):
last_name = Column(String)
hashed_password = Column(String, nullable=False)
# Platform-level role only (admin or store)
role = Column(String, nullable=False, default=UserRole.STORE.value)
# 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)
# Super admin flag (only meaningful when role='admin')
# Super admins have access to ALL platforms and global settings
# Platform admins (is_super_admin=False) are assigned to specific platforms
is_super_admin = Column(Boolean, default=False, nullable=False)
# Language preference (NULL = use context default: store dashboard_language or system default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
@@ -92,15 +91,34 @@ class User(Base, TimestampMixin):
return f"{self.first_name} {self.last_name}"
return self.username
@property
def is_admin(self) -> bool:
"""Check if user is a platform admin."""
return self.role == UserRole.ADMIN.value
# =========================================================================
# Role-checking properties
# =========================================================================
@property
def is_store(self) -> bool:
"""Check if user is a store (owner or team member)."""
return self.role == UserRole.STORE.value
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:
"""
@@ -155,16 +173,6 @@ class User(Base, TimestampMixin):
# Admin Platform Access Methods
# =========================================================================
@property
def is_super_admin_user(self) -> bool:
"""Check if user is a super admin (can access all platforms)."""
return self.role == UserRole.ADMIN.value and self.is_super_admin
@property
def is_platform_admin(self) -> bool:
"""Check if user is a platform admin (access to assigned platforms only)."""
return self.role == UserRole.ADMIN.value and not self.is_super_admin
def can_access_platform(self, platform_id: int) -> bool:
"""
Check if admin can access a specific platform.

View File

@@ -57,7 +57,7 @@ def admin_login(
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
# Verify user is admin
if login_result["user"].role != "admin":
if not login_result["user"].is_admin:
logger.warning(
f"Non-admin user attempted admin login: {user_credentials.email_or_username}"
)

View File

@@ -92,7 +92,7 @@ def get_all_users(
store_id=su.store_id,
store_code=su.store.store_code,
store_name=su.store.name,
user_type=su.user_type,
role=su.user.role,
is_active=su.is_active,
)
for su in (user.store_memberships or [])
@@ -299,7 +299,7 @@ def get_user_details(
store_id=su.store_id,
store_code=su.store.store_code,
store_name=su.store.name,
user_type=su.user_type,
role=su.user.role,
is_active=su.is_active,
)
for su in (user.store_memberships or [])

View File

@@ -57,7 +57,7 @@ class AdminUserResponse(BaseModel):
first_name: str | None = None
last_name: str | None = None
is_active: bool
is_super_admin: bool
role: str
platform_assignments: list[PlatformAssignmentResponse] = []
created_at: datetime
updated_at: datetime
@@ -82,7 +82,7 @@ class CreateAdminUserRequest(BaseModel):
password: str
first_name: str | None = None
last_name: str | None = None
is_super_admin: bool = False
role: str = "platform_admin"
platform_ids: list[int] = []
@@ -93,9 +93,9 @@ class AssignPlatformRequest(BaseModel):
class ToggleSuperAdminRequest(BaseModel):
"""Request to toggle super admin status."""
"""Request to change admin role (super_admin or platform_admin)."""
is_super_admin: bool
role: str # "super_admin" or "platform_admin"
# ============================================================================
@@ -125,7 +125,7 @@ def _build_admin_response(admin: User) -> AdminUserResponse:
first_name=admin.first_name,
last_name=admin.last_name,
is_active=admin.is_active,
is_super_admin=admin.is_super_admin,
role=admin.role,
platform_assignments=assignments,
created_at=admin.created_at,
updated_at=admin.updated_at,
@@ -174,14 +174,14 @@ def create_admin_user(
Super admin only.
"""
# Validate platform_ids required for non-super admin
if not request.is_super_admin and not request.platform_ids:
# Validate platform_ids required for platform admins
if request.role != "super_admin" and not request.platform_ids:
raise ValidationException(
"Platform admins must be assigned to at least one platform",
field="platform_ids",
)
if request.is_super_admin:
if request.role == "super_admin":
# Create super admin using service
user = admin_platform_service.create_super_admin(
db=db,
@@ -202,7 +202,7 @@ def create_admin_user(
first_name=user.first_name,
last_name=user.last_name,
is_active=user.is_active,
is_super_admin=user.is_super_admin,
role=user.role,
platform_assignments=[],
)
# Create platform admin with assignments using service
@@ -306,17 +306,17 @@ def toggle_super_admin_status(
user = admin_platform_service.toggle_super_admin(
db=db,
user_id=user_id,
is_super_admin=request.is_super_admin,
is_super_admin=(request.role == "super_admin"),
current_admin_id=current_admin.id,
)
db.commit()
action = "promoted to" if request.is_super_admin else "demoted from"
action = "promoted to" if request.role == "super_admin" else "demoted from"
return {
"message": f"Admin {action} super admin successfully",
"user_id": user_id,
"is_super_admin": user.is_super_admin,
"role": user.role,
}

View File

@@ -77,7 +77,7 @@ def store_login(
user = login_result["user"]
# CRITICAL: Prevent admin users from using store login
if user.role == "admin":
if user.is_admin:
logger.warning(f"Admin user attempted store login: {user.username}")
raise InvalidCredentialsException(
"Admins cannot access store portal. Please use admin portal."

View File

@@ -118,7 +118,6 @@ class TeamMemberResponse(BaseModel):
first_name: str | None
last_name: str | None
full_name: str
user_type: str = Field(..., description="'owner' or 'member'")
role_name: str = Field(..., description="Role name")
role_id: int | None
permissions: list[str] = Field(

View File

@@ -344,7 +344,7 @@ class AdminPlatformService:
reason="User must be an admin to be promoted to super admin",
)
user.is_super_admin = is_super_admin
user.role = "super_admin" if is_super_admin else "platform_admin"
user.updated_at = datetime.now(UTC)
db.flush()
db.refresh(user)
@@ -402,9 +402,8 @@ class AdminPlatformService:
hashed_password=auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role="admin",
role="platform_admin",
is_active=True,
is_super_admin=False, # Platform admin, not super admin
)
db.add(user)
db.flush()
@@ -459,10 +458,12 @@ class AdminPlatformService:
Returns:
Tuple of (list of User objects, total count)
"""
query = db.query(User).filter(User.role == "admin")
query = db.query(User).filter(
User.role.in_(["super_admin", "platform_admin"])
)
if not include_super_admins:
query = query.filter(User.is_super_admin == False)
query = query.filter(User.role == "platform_admin")
if is_active is not None:
query = query.filter(User.is_active == is_active)
@@ -508,7 +509,10 @@ class AdminPlatformService:
admin = (
db.query(User)
.options(joinedload(User.admin_platforms))
.filter(User.id == user_id, User.role == "admin")
.filter(
User.id == user_id,
User.role.in_(["super_admin", "platform_admin"]),
)
.first()
)
@@ -560,8 +564,7 @@ class AdminPlatformService:
hashed_password=get_password_hash(password),
first_name=first_name,
last_name=last_name,
role="admin",
is_super_admin=True,
role="super_admin",
is_active=True,
)
db.add(user)
@@ -601,7 +604,10 @@ class AdminPlatformService:
operation="deactivate own account",
)
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
admin = db.query(User).filter(
User.id == user_id,
User.role.in_(["super_admin", "platform_admin"]),
).first()
if not admin:
raise UserNotFoundException(str(user_id))
@@ -642,7 +648,10 @@ class AdminPlatformService:
operation="delete own account",
)
admin = db.query(User).filter(User.id == user_id, User.role == "admin").first()
admin = db.query(User).filter(
User.id == user_id,
User.role.in_(["super_admin", "platform_admin"]),
).first()
if not admin:
raise UserNotFoundException(str(user_id))

View File

@@ -69,7 +69,7 @@ class AdminService:
raise CannotModifySelfException(user_id, "deactivate account")
# Check if user is another admin
if user.role == "admin" and user.id != current_admin_id:
if user.is_admin and user.id != current_admin_id:
raise UserStatusChangeException(
user_id=user_id,
current_status="admin",
@@ -248,7 +248,7 @@ class AdminService:
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent changing own admin status
if user.id == current_admin_id and role and role != "admin":
if user.id == current_admin_id and role and role not in ("super_admin", "platform_admin"):
raise UserRoleChangeException(
user_id=user_id,
current_role=user.role,

View File

@@ -69,7 +69,7 @@ class MerchantService:
username=merchant_data.owner_email.split("@")[0],
email=merchant_data.owner_email,
hashed_password=auth_manager.hash_password(temp_password),
role="user",
role="merchant_owner",
is_active=True,
is_email_verified=False,
)

View File

@@ -80,7 +80,7 @@ class StoreService:
# Check if user is merchant owner or admin
if (
current_user.role != "admin"
not current_user.is_admin
and merchant.owner_user_id != current_user.id
):
raise UnauthorizedStoreAccessException(
@@ -105,7 +105,7 @@ class StoreService:
letzshop_csv_url_en=store_data.letzshop_csv_url_en,
letzshop_csv_url_de=store_data.letzshop_csv_url_de,
is_active=True,
is_verified=(current_user.role == "admin"),
is_verified=current_user.is_admin,
)
db.add(new_store)
@@ -153,7 +153,7 @@ class StoreService:
query = db.query(Store)
# Non-admin users can only see active and verified stores, plus their own
if current_user.role != "admin":
if not current_user.is_admin:
# Get store IDs the user owns through merchants
from app.modules.tenancy.models import Merchant
@@ -457,7 +457,7 @@ class StoreService:
def _can_access_store(self, store: Store, user: User) -> bool:
"""Check if user can access store."""
# Admins can always access
if user.role == "admin":
if user.is_admin:
return True
# Merchant owners can access their stores
@@ -481,7 +481,7 @@ class StoreService:
- Team members with appropriate role (owner role in StoreUser)
"""
# Admins can always update
if user.role == "admin":
if user.is_admin:
return True
# Check if user is store owner via merchant

View File

@@ -33,7 +33,7 @@ from app.modules.tenancy.exceptions import (
TeamMemberAlreadyExistsException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, StoreUserType, User
from app.modules.tenancy.models import Role, Store, StoreUser, User
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
@@ -134,7 +134,7 @@ class StoreTeamService:
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(temp_password),
role="store", # Platform role
role="store_member",
is_active=False, # Will be activated when invitation accepted
is_email_verified=False,
)
@@ -157,7 +157,6 @@ class StoreTeamService:
store_user = StoreUser(
store_id=store.id,
user_id=user.id,
user_type=StoreUserType.TEAM_MEMBER.value,
role_id=role.id,
invited_by=inviter.id,
invitation_token=invitation_token,
@@ -416,7 +415,6 @@ class StoreTeamService:
"first_name": vu.user.first_name,
"last_name": vu.user.last_name,
"full_name": vu.user.full_name,
"user_type": vu.user_type,
"role_name": vu.role.name if vu.role else "owner",
"role_id": vu.role.id if vu.role else None,
"permissions": vu.get_all_permissions(),

View File

@@ -216,7 +216,7 @@ class TenancyMetricsProvider:
db.query(User)
.filter(
User.id.in_(platform_user_ids),
User.role == "admin",
User.role.in_(["super_admin", "platform_admin"]),
)
.count()
)
@@ -230,11 +230,9 @@ class TenancyMetricsProvider:
)
# Team members: distinct StoreUser users who are NOT merchant owners
# Uses user_type="member" AND excludes owner user IDs to avoid overlap
team_members = (
db.query(func.count(func.distinct(StoreUser.user_id)))
.filter(
StoreUser.user_type == "member",
~StoreUser.user_id.in_(db.query(Merchant.owner_user_id)),
)
.scalar() or 0

View File

@@ -86,7 +86,7 @@ function adminUserDetailPage() {
adminUserDetailLog.info(`Admin user loaded in ${duration}ms`, {
id: this.adminUser.id,
username: this.adminUser.username,
is_super_admin: this.adminUser.is_super_admin,
role: this.adminUser.role,
is_active: this.adminUser.is_active
});
adminUserDetailLog.debug('Full admin user data:', this.adminUser);

View File

@@ -101,7 +101,7 @@ function adminUserEditPage() {
adminUserEditLog.info(`Admin user loaded in ${duration}ms`, {
id: this.adminUser.id,
username: this.adminUser.username,
is_super_admin: this.adminUser.is_super_admin
role: this.adminUser.role
});
} catch (error) {
@@ -128,7 +128,7 @@ function adminUserEditPage() {
// Get available platforms (not yet assigned)
get availablePlatformsForAssignment() {
if (!this.adminUser || this.adminUser.is_super_admin) return [];
if (!this.adminUser || this.adminUser.role === 'super_admin') return [];
const assignedIds = (this.adminUser.platforms || []).map(p => p.id);
return this.platforms.filter(p => !assignedIds.includes(p.id));
},
@@ -143,12 +143,13 @@ function adminUserEditPage() {
// Toggle super admin status
async toggleSuperAdmin() {
const newStatus = !this.adminUser.is_super_admin;
const action = newStatus ? 'promote to' : 'demote from';
const isSuperAdmin = this.adminUser.role === 'super_admin';
const newRole = isSuperAdmin ? 'platform_admin' : 'super_admin';
const action = !isSuperAdmin ? 'promote to' : 'demote from';
adminUserEditLog.info(`Toggle super admin: ${action}`);
// Prevent self-demotion
if (this.adminUser.id === this.currentUserId && !newStatus) {
if (this.adminUser.id === this.currentUserId && isSuperAdmin) {
Utils.showToast(I18n.t('tenancy.messages.you_cannot_demote_yourself_from_super_ad'), 'error');
return;
}
@@ -156,19 +157,19 @@ function adminUserEditPage() {
this.saving = true;
try {
const url = `/admin/admin-users/${this.userId}/super-admin`;
window.LogConfig.logApiCall('PUT', url, { is_super_admin: newStatus }, 'request');
window.LogConfig.logApiCall('PUT', url, { role: newRole }, 'request');
const response = await apiClient.put(url, { is_super_admin: newStatus });
const response = await apiClient.put(url, { role: newRole });
window.LogConfig.logApiCall('PUT', url, response, 'response');
this.adminUser.is_super_admin = response.is_super_admin;
this.adminUser.role = response.role;
// Clear platforms if promoted to super admin
if (response.is_super_admin) {
if (response.role === 'super_admin') {
this.adminUser.platforms = [];
}
const actionDone = newStatus ? 'promoted to' : 'demoted from';
const actionDone = newRole === 'super_admin' ? 'promoted to' : 'demoted from';
Utils.showToast(`Admin ${actionDone} super admin successfully`, 'success');
adminUserEditLog.info(`Admin ${actionDone} super admin successfully`);

View File

@@ -22,7 +22,7 @@ function adminUsersPage() {
adminToDelete: null,
filters: {
search: '',
is_super_admin: '',
role: '',
is_active: ''
},
stats: {
@@ -143,7 +143,7 @@ function adminUsersPage() {
if (this.filters.search) {
params.append('search', this.filters.search);
}
if (this.filters.is_super_admin === 'false') {
if (this.filters.role === 'platform_admin') {
params.append('include_super_admins', 'false');
}
if (this.filters.is_active !== '') {
@@ -174,9 +174,11 @@ function adminUsersPage() {
);
}
// Filter by super admin status
if (this.filters.is_super_admin === 'true') {
admins = admins.filter(admin => admin.is_super_admin);
// Filter by role
if (this.filters.role === 'super_admin') {
admins = admins.filter(admin => admin.role === 'super_admin');
} else if (this.filters.role === 'platform_admin') {
admins = admins.filter(admin => admin.role === 'platform_admin');
}
// Filter by active status
@@ -227,8 +229,8 @@ function adminUsersPage() {
// Compute stats from the data
this.stats = {
total_admins: admins.length,
super_admins: admins.filter(a => a.is_super_admin).length,
platform_admins: admins.filter(a => !a.is_super_admin).length,
super_admins: admins.filter(a => a.role === 'super_admin').length,
platform_admins: admins.filter(a => a.role === 'platform_admin').length,
active_admins: admins.filter(a => a.is_active).length
};

View File

@@ -46,7 +46,7 @@ function selectPlatform() {
const response = await apiClient.get('/admin/auth/accessible-platforms');
platformLog.debug('Platforms response:', response);
this.isSuperAdmin = response.is_super_admin;
this.isSuperAdmin = response.role === 'super_admin';
this.platforms = response.platforms || [];
if (this.isSuperAdmin) {

View File

@@ -17,7 +17,7 @@ function adminUserCreate() {
password: '',
first_name: '',
last_name: '',
is_super_admin: false,
role: 'platform_admin',
platform_ids: []
},
platforms: [],
@@ -72,7 +72,7 @@ function adminUserCreate() {
}
// Platform admin validation: must have at least one platform
if (!this.formData.is_super_admin) {
if (this.formData.role !== 'super_admin') {
if (!this.formData.platform_ids || this.formData.platform_ids.length === 0) {
this.errors.platform_ids = 'Platform admins must be assigned to at least one platform';
}
@@ -103,8 +103,8 @@ function adminUserCreate() {
password: this.formData.password,
first_name: this.formData.first_name || null,
last_name: this.formData.last_name || null,
is_super_admin: this.formData.is_super_admin,
platform_ids: this.formData.is_super_admin ? [] : this.formData.platform_ids.map(id => parseInt(id))
role: this.formData.role,
platform_ids: this.formData.role === 'super_admin' ? [] : this.formData.platform_ids.map(id => parseInt(id))
};
window.LogConfig.logApiCall('POST', url, { ...payload, password: '[REDACTED]' }, 'request');
@@ -116,7 +116,7 @@ function adminUserCreate() {
window.LogConfig.logApiCall('POST', url, response, 'response');
window.LogConfig.logPerformance('Create Admin User', duration);
const userType = this.formData.is_super_admin ? 'Super admin' : 'Platform admin';
const userType = this.formData.role === 'super_admin' ? 'Super admin' : 'Platform admin';
Utils.showToast(`${userType} created successfully`, 'success');
userCreateLog.info(`${userType} created successfully in ${duration}ms`, response);

View File

@@ -58,16 +58,16 @@
<!-- Admin Type -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="adminUser?.is_super_admin
:class="adminUser?.role === 'super_admin'
? 'text-orange-500 bg-orange-100 dark:text-orange-100 dark:bg-orange-500'
: 'text-purple-500 bg-purple-100 dark:text-purple-100 dark:bg-purple-500'">
<span x-html="$icon(adminUser?.is_super_admin ? 'star' : 'shield', 'w-5 h-5')"></span>
<span x-html="$icon(adminUser?.role === 'super_admin' ? 'star' : 'shield', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Admin Type
Role
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_super_admin ? 'Super Admin' : 'Platform Admin'">
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.role === 'super_admin' ? 'Super Admin' : 'Platform Admin'">
-
</p>
</div>
@@ -98,7 +98,7 @@
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Platforms
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.is_super_admin ? 'All' : ((adminUser?.platforms || []).length || 0)">
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="adminUser?.role === 'super_admin' ? 'All' : ((adminUser?.platforms || []).length || 0)">
0
</p>
</div>
@@ -170,7 +170,7 @@
</div>
<!-- Platform Access (for platform admins) -->
<template x-if="!adminUser?.is_super_admin">
<template x-if="adminUser?.role !== 'super_admin'">
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Platform Access
@@ -197,7 +197,7 @@
</template>
<!-- Super Admin Notice -->
<template x-if="adminUser?.is_super_admin">
<template x-if="adminUser?.role === 'super_admin'">
<div class="px-4 py-3 mb-8 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div class="flex items-center">
<span x-html="$icon('star', 'w-5 h-5 text-orange-500 mr-3')"></span>

View File

@@ -37,23 +37,23 @@
<!-- Toggle Super Admin -->
<button
@click="showToggleSuperAdminModal = true"
:disabled="saving || (adminUser?.id === currentUserId && adminUser?.is_super_admin)"
:disabled="saving || (adminUser?.id === currentUserId && adminUser?.role === 'super_admin')"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 rounded-lg focus:outline-none disabled:opacity-50"
:class="adminUser?.is_super_admin ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-purple-600 hover:bg-purple-700'"
:title="adminUser?.id === currentUserId && adminUser?.is_super_admin ? 'Cannot demote yourself' : ''">
<span x-html="$icon(adminUser?.is_super_admin ? 'shield-x' : 'shield-check', 'w-4 h-4 mr-2')"></span>
<span x-text="adminUser?.is_super_admin ? 'Demote from Super Admin' : 'Promote to Super Admin'"></span>
:class="adminUser?.role === 'super_admin' ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-purple-600 hover:bg-purple-700'"
:title="adminUser?.id === currentUserId && adminUser?.role === 'super_admin' ? 'Cannot demote yourself' : ''">
<span x-html="$icon(adminUser?.role === 'super_admin' ? 'shield-x' : 'shield-check', 'w-4 h-4 mr-2')"></span>
<span x-text="adminUser?.role === 'super_admin' ? 'Demote from Super Admin' : 'Promote to Super Admin'"></span>
</button>
<!-- Status Badges -->
<div class="ml-auto flex items-center gap-2">
<span
x-show="adminUser?.is_super_admin"
x-show="adminUser?.role === 'super_admin'"
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-yellow-700 bg-yellow-100 rounded-full dark:bg-yellow-700 dark:text-yellow-100">
Super Admin
</span>
<span
x-show="!adminUser?.is_super_admin"
x-show="adminUser?.role !== 'super_admin'"
class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-purple-700 bg-purple-100 rounded-full dark:bg-purple-700 dark:text-purple-100">
Platform Admin
</span>
@@ -107,7 +107,7 @@
</div>
<!-- Platform Assignments Card (Only for Platform Admins) -->
<template x-if="!adminUser?.is_super_admin">
<template x-if="adminUser?.role !== 'super_admin'">
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
@@ -160,7 +160,7 @@
</template>
<!-- Super Admin Notice -->
<template x-if="adminUser?.is_super_admin">
<template x-if="adminUser?.role === 'super_admin'">
<div class="px-4 py-3 mb-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex items-center">
<span x-html="$icon('shield-check', 'w-6 h-6 text-yellow-600 dark:text-yellow-400 mr-3')"></span>
@@ -270,7 +270,7 @@
{{ confirm_modal_dynamic(
'toggleSuperAdminModal',
'Toggle Super Admin',
"'Are you sure you want to ' + (adminUser?.is_super_admin ? 'demote' : 'promote') + ' \"' + (adminUser?.username || '') + '\" ' + (adminUser?.is_super_admin ? 'from' : 'to') + ' super admin?'",
"'Are you sure you want to ' + (adminUser?.role === 'super_admin' ? 'demote' : 'promote') + ' \"' + (adminUser?.username || '') + '\" ' + (adminUser?.role === 'super_admin' ? 'from' : 'to') + ' super admin?'",
'toggleSuperAdmin()',
'showToggleSuperAdminModal',
'Confirm',

View File

@@ -101,15 +101,15 @@
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Admin Type Filter -->
<!-- Admin Role Filter -->
<select
x-model="filters.is_super_admin"
x-model="filters.role"
@change="pagination.page = 1; loadAdminUsers()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Admin Types</option>
<option value="true">Super Admins</option>
<option value="false">Platform Admins</option>
<option value="">All Admin Roles</option>
<option value="super_admin">Super Admins</option>
<option value="platform_admin">Platform Admins</option>
</select>
<!-- Status Filter -->
@@ -139,7 +139,7 @@
<!-- Admin Users Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['Admin', 'Email', 'Type', 'Platforms', 'Status', 'Last Login', 'Actions']) }}
{{ table_header(['Admin', 'Email', 'Role', 'Platforms', 'Status', 'Last Login', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="adminUsers.length === 0">
@@ -162,7 +162,7 @@
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full flex items-center justify-center text-white font-semibold text-sm"
:class="admin.is_super_admin ? 'bg-orange-500' : 'bg-purple-500'"
:class="admin.role === 'super_admin' ? 'bg-orange-500' : 'bg-purple-500'"
x-text="(admin.username || 'A').charAt(0).toUpperCase()">
</div>
</div>
@@ -176,22 +176,22 @@
<!-- Email -->
<td class="px-4 py-3 text-sm" x-text="admin.email"></td>
<!-- Type -->
<!-- Role -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="admin.is_super_admin
:class="admin.role === 'super_admin'
? 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'
: 'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100'"
x-text="admin.is_super_admin ? 'Super Admin' : 'Platform Admin'">
x-text="admin.role === 'super_admin' ? 'Super Admin' : 'Platform Admin'">
</span>
</td>
<!-- Platforms -->
<td class="px-4 py-3 text-sm">
<template x-if="admin.is_super_admin">
<template x-if="admin.role === 'super_admin'">
<span class="text-gray-500 dark:text-gray-400 italic">All platforms</span>
</template>
<template x-if="!admin.is_super_admin">
<template x-if="admin.role !== 'super_admin'">
<div class="flex flex-wrap gap-1">
<template x-for="platform in (admin.platforms || []).slice(0, 3)" :key="platform.id">
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="platform.code"></span>

View File

@@ -216,7 +216,7 @@
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span x-text="membership.store_code"></span>
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
x-text="membership.user_type"></span>
x-text="membership.is_owner ? 'Owner' : 'Member'"></span>
</div>
</a>
</template>

View File

@@ -78,24 +78,26 @@
<div class="mt-4 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h4 class="text-sm font-medium text-purple-800 dark:text-purple-300 mb-3">Admin Settings</h4>
<!-- Super Admin Toggle -->
<label class="flex items-center mb-4">
<input
type="checkbox"
x-model="formData.is_super_admin"
:disabled="saving"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700"
>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Super Admin
<!-- Admin Role Selection -->
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Admin Role <span class="text-red-600">*</span>
</span>
<select
x-model="formData.role"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select"
>
<option value="platform_admin">Platform Admin</option>
<option value="super_admin">Super Admin</option>
</select>
</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4 -mt-2 ml-6">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4 -mt-2">
Super admins have access to all platforms and can manage other admins.
</p>
<!-- Platform Assignment (only if not super admin) -->
<template x-if="!formData.is_super_admin">
<template x-if="formData.role !== 'super_admin'">
<div>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">

View File

@@ -34,7 +34,7 @@ def ev_unverified_user(db):
email=f"unverified_{uid}@test.com",
username=f"unverified_{uid}",
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=False,
)
@@ -55,7 +55,7 @@ def ev_verified_user(db):
email=f"verified_{uid}@test.com",
username=f"verified_{uid}",
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)

View File

@@ -33,7 +33,7 @@ def ma_owner(db):
email=f"maowner_{uid}@test.com",
username=f"maowner_{uid}",
hashed_password=auth.hash_password("mapass123"),
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
@@ -70,7 +70,7 @@ def ma_non_merchant_user(db):
email=f"nonmerch_{uid}@test.com",
username=f"nonmerch_{uid}",
hashed_password=auth.hash_password("nonmerch123"),
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
@@ -91,7 +91,7 @@ def ma_inactive_user(db):
email=f"inactive_{uid}@test.com",
username=f"inactive_{uid}",
hashed_password=auth.hash_password("inactive123"),
role="store",
role="merchant_owner",
is_active=False,
)
db.add(user)

View File

@@ -32,7 +32,7 @@ def mpr_user(db):
email=f"mpr_{uid}@test.com",
username=f"mpr_{uid}",
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
@@ -69,7 +69,7 @@ def mpr_inactive_user(db):
email=f"mpri_{uid}@test.com",
username=f"mpri_{uid}",
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=False,
is_email_verified=True,
)

View File

@@ -36,7 +36,7 @@ def mt_owner(db):
email=f"mtowner_{uid}@test.com",
username=f"mtowner_{uid}",
hashed_password=auth.hash_password("mtpass123"),
role="store",
role="merchant_owner",
is_active=True,
)
db.add(user)
@@ -95,7 +95,7 @@ def mt_auth(mt_owner, mt_merchant):
id=mt_owner.id,
email=mt_owner.email,
username=mt_owner.username,
role="store",
role="merchant_owner",
is_active=True,
)

View File

@@ -12,7 +12,6 @@ import uuid
import pytest
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
from app.modules.tenancy.models.store import StoreUserType
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
# ============================================================================
@@ -33,7 +32,7 @@ def spr_owner(db):
email=f"spr_{uid}@test.com",
username=f"spr_{uid}",
hashed_password=auth.hash_password("storepass123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
@@ -83,7 +82,6 @@ def spr_store_user(db, spr_owner, spr_store):
store_user = StoreUser(
user_id=spr_owner.id,
store_id=spr_store.id,
user_type=StoreUserType.OWNER.value,
is_active=True,
)
db.add(store_user)

View File

@@ -75,9 +75,8 @@ class TestAdminPlatformModel:
email="temp_admin@example.com",
username="temp_admin",
hashed_password=auth_manager.hash_password("temppass"),
role="admin",
role="platform_admin",
is_active=True,
is_super_admin=False,
)
db.add(temp_admin)
db.flush()
@@ -168,13 +167,13 @@ class TestAdminPlatformModel:
class TestUserAdminMethods:
"""Test User model admin-related methods."""
def test_is_super_admin_user_true(self, db, test_super_admin):
"""Test is_super_admin_user property for super admin."""
assert test_super_admin.is_super_admin_user is True
def test_is_super_admin_true(self, db, test_super_admin):
"""Test is_super_admin property for super admin."""
assert test_super_admin.is_super_admin is True
def test_is_super_admin_user_false_for_platform_admin(self, db, test_platform_admin):
"""Test is_super_admin_user property for platform admin."""
assert test_platform_admin.is_super_admin_user is False
def test_is_super_admin_false_for_platform_admin(self, db, test_platform_admin):
"""Test is_super_admin property for platform admin."""
assert test_platform_admin.is_super_admin is False
def test_is_platform_admin_true(self, db, test_platform_admin):
"""Test is_platform_admin property for platform admin."""

View File

@@ -256,9 +256,8 @@ class TestAdminPlatformServiceQueries:
email="another_padmin@example.com",
username="another_padmin",
hashed_password=auth_manager.hash_password("pass"), # noqa: SEC001
role="admin",
role="platform_admin",
is_active=True,
is_super_admin=False,
)
db.add(another_admin)
db.flush()
@@ -343,9 +342,8 @@ class TestAdminPlatformServiceSuperAdmin:
email="another_super@example.com",
username="another_super",
hashed_password=auth_manager.hash_password("pass"), # noqa: SEC001
role="admin",
role="super_admin",
is_active=True,
is_super_admin=True,
)
db.add(another_super)
db.commit()
@@ -427,8 +425,8 @@ class TestAdminPlatformServiceCreatePlatformAdmin:
assert user is not None
assert user.email == "new_padmin@example.com"
assert user.username == "new_padmin"
assert user.role == "admin"
assert user.is_super_admin is False
assert user.role == "platform_admin"
assert user.is_super_admin is False # platform_admin, not super_admin
assert user.first_name == "New"
assert user.last_name == "Admin"
assert len(assignments) == 2

View File

@@ -23,7 +23,7 @@ class TestEmailVerificationToken:
email=f"evk_{uid}@test.com",
username=f"evk_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=False,
)

View File

@@ -11,7 +11,7 @@ from app.modules.tenancy.exceptions import (
InvalidInvitationTokenException,
UserNotFoundException,
)
from app.modules.tenancy.models import Role, Store, StoreUser, StoreUserType, User
from app.modules.tenancy.models import Role, Store, StoreUser, User
from app.modules.tenancy.services.store_team_service import store_team_service
# =============================================================================
@@ -43,7 +43,6 @@ def store_owner(db, team_store, test_user):
store_user = StoreUser(
store_id=team_store.id,
user_id=test_user.id,
user_type=StoreUserType.OWNER.value,
is_active=True,
)
db.add(store_user)
@@ -67,7 +66,6 @@ def team_member(db, team_store, other_user, auth_manager):
store_user = StoreUser(
store_id=team_store.id,
user_id=other_user.id,
user_type=StoreUserType.TEAM_MEMBER.value,
role_id=role.id,
is_active=True,
invitation_accepted_at=datetime.utcnow(),
@@ -88,7 +86,7 @@ def pending_invitation(db, team_store, test_user, auth_manager):
email=f"pending_{unique_id}@example.com",
username=f"pending_{unique_id}",
hashed_password=auth_manager.hash_password("temppass"), # noqa: SEC001
role="store",
role="store_member",
is_active=False,
)
db.add(new_user)
@@ -107,7 +105,6 @@ def pending_invitation(db, team_store, test_user, auth_manager):
store_user = StoreUser(
store_id=team_store.id,
user_id=new_user.id,
user_type=StoreUserType.TEAM_MEMBER.value,
role_id=role.id,
invited_by=test_user.id,
invitation_token=f"pending_token_{unique_id}",
@@ -130,7 +127,7 @@ def expired_invitation(db, team_store, test_user, auth_manager):
email=f"expired_{unique_id}@example.com",
username=f"expired_{unique_id}",
hashed_password=auth_manager.hash_password("temppass"), # noqa: SEC001
role="store",
role="store_member",
is_active=False,
)
db.add(new_user)
@@ -149,7 +146,6 @@ def expired_invitation(db, team_store, test_user, auth_manager):
store_user = StoreUser(
store_id=team_store.id,
user_id=new_user.id,
user_type=StoreUserType.TEAM_MEMBER.value,
role_id=role.id,
invited_by=test_user.id,
invitation_token=f"expired_token_{unique_id}",

View File

@@ -32,7 +32,7 @@ class TestUserAuthServicePasswordReset:
email=f"uas_{uid}@test.com",
username=f"uas_{uid}",
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
@@ -52,7 +52,7 @@ class TestUserAuthServicePasswordReset:
email=f"uasi_{uid}@test.com",
username=f"uasi_{uid}",
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
role="store",
role="merchant_owner",
is_active=False,
is_email_verified=True,
)
@@ -118,7 +118,7 @@ class TestUserAuthServiceEmailVerification:
email=f"uasev_{uid}@test.com",
username=f"uasev_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=False,
)
@@ -135,7 +135,7 @@ class TestUserAuthServiceEmailVerification:
email=f"uasv_{uid}@test.com",
username=f"uasv_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)

View File

@@ -18,7 +18,7 @@ class TestUserModel:
email="db_test@example.com",
username="dbtest",
hashed_password="hashed_password_123", # noqa: SEC001
role="user",
role="store_member",
is_active=True,
)
@@ -29,7 +29,7 @@ class TestUserModel:
assert user.id is not None
assert user.email == "db_test@example.com"
assert user.username == "dbtest"
assert user.role == "user"
assert user.role == "store_member"
assert user.is_active is True
assert user.created_at is not None
assert user.updated_at is not None
@@ -86,7 +86,7 @@ class TestUserModel:
db.refresh(user)
assert user.is_active is True # Default
assert user.role == "store" # Default (UserRole.STORE)
assert user.role == "store_member" # Default (UserRole.STORE_MEMBER)
def test_user_optional_fields(self, db):
"""Test User model with optional fields."""

View File

@@ -23,7 +23,7 @@ class TestUserPasswordResetToken:
email=f"prt_{uid}@test.com",
username=f"prt_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
role="merchant_owner",
is_active=True,
is_email_verified=True,
)