All checks were successful
Refactor 10 db.add() loops to db.add_all() in services (menu, admin, orders, dev_tools), suppress 65 in tests/seeds/complex patterns with noqa: PERF006, suppress 2 polling interval warnings with noqa: PERF062, and add JS comment noqa support to base validator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
537 lines
17 KiB
Python
537 lines
17 KiB
Python
# app/modules/tenancy/services/store_team_service.py
|
|
"""
|
|
Store team management service.
|
|
|
|
Handles:
|
|
- Team member invitations
|
|
- Invitation acceptance
|
|
- Role assignment
|
|
- Permission management
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.tenancy.services.permission_discovery_service import (
|
|
permission_discovery_service,
|
|
)
|
|
|
|
|
|
def get_preset_permissions(preset_name: str) -> set[str]:
|
|
"""Get permissions for a preset role."""
|
|
return permission_discovery_service.get_preset_permissions(preset_name)
|
|
from app.modules.billing.exceptions import TierLimitExceededException
|
|
from app.modules.tenancy.exceptions import (
|
|
CannotRemoveOwnerException,
|
|
InvalidInvitationTokenException,
|
|
TeamInvitationAlreadyAcceptedException,
|
|
TeamMemberAlreadyExistsException,
|
|
UserNotFoundException,
|
|
)
|
|
from app.modules.tenancy.models import Role, Store, StoreUser, StoreUserType, User
|
|
from middleware.auth import AuthManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class StoreTeamService:
|
|
"""Service for managing store team members."""
|
|
|
|
def __init__(self):
|
|
self.auth_manager = AuthManager()
|
|
|
|
def invite_team_member(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
inviter: User,
|
|
email: str,
|
|
role_name: str,
|
|
custom_permissions: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Invite a new team member to a store.
|
|
|
|
Creates:
|
|
1. User account (if doesn't exist)
|
|
2. Role (if custom permissions provided)
|
|
3. StoreUser relationship with invitation token
|
|
|
|
Args:
|
|
db: Database session
|
|
store: Store to invite to
|
|
inviter: User sending the invitation
|
|
email: Email of person to invite
|
|
role_name: Role name (manager, staff, support, etc.)
|
|
custom_permissions: Optional custom permissions (overrides preset)
|
|
|
|
Returns:
|
|
Dict with invitation details
|
|
"""
|
|
try:
|
|
# Check team size limit from subscription
|
|
from app.modules.billing.services import subscription_service
|
|
|
|
subscription_service.check_team_limit(db, store.id)
|
|
|
|
# Check if user already exists
|
|
user = db.query(User).filter(User.email == email).first()
|
|
|
|
if user:
|
|
# Check if already a member
|
|
existing_membership = (
|
|
db.query(StoreUser)
|
|
.filter(
|
|
StoreUser.store_id == store.id,
|
|
StoreUser.user_id == user.id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if existing_membership:
|
|
if existing_membership.is_active:
|
|
raise TeamMemberAlreadyExistsException(
|
|
email, store.store_code
|
|
)
|
|
# Reactivate old membership
|
|
existing_membership.is_active = (
|
|
False # Will be activated on acceptance
|
|
)
|
|
existing_membership.invitation_token = (
|
|
self._generate_invitation_token()
|
|
)
|
|
existing_membership.invitation_sent_at = datetime.utcnow()
|
|
existing_membership.invitation_accepted_at = None
|
|
db.flush()
|
|
|
|
logger.info(
|
|
f"Re-invited user {email} to store {store.store_code}"
|
|
)
|
|
return {
|
|
"invitation_token": existing_membership.invitation_token,
|
|
"email": email,
|
|
"existing_user": True,
|
|
}
|
|
else:
|
|
# Create new user account (inactive until invitation accepted)
|
|
username = email.split("@")[0]
|
|
# Ensure unique username
|
|
base_username = username
|
|
counter = 1
|
|
while db.query(User).filter(User.username == username).first():
|
|
username = f"{base_username}{counter}"
|
|
counter += 1
|
|
|
|
# Generate temporary password (user will set real one on activation)
|
|
temp_password = secrets.token_urlsafe(16)
|
|
|
|
user = User(
|
|
email=email,
|
|
username=username,
|
|
hashed_password=self.auth_manager.hash_password(temp_password),
|
|
role="store", # Platform role
|
|
is_active=False, # Will be activated when invitation accepted
|
|
is_email_verified=False,
|
|
)
|
|
db.add(user)
|
|
db.flush() # Get user.id
|
|
|
|
logger.info(f"Created new user account for invitation: {email}")
|
|
|
|
# Get or create role
|
|
role = self._get_or_create_role(
|
|
db=db,
|
|
store=store,
|
|
role_name=role_name,
|
|
custom_permissions=custom_permissions,
|
|
)
|
|
|
|
# Create store membership with invitation
|
|
invitation_token = self._generate_invitation_token()
|
|
|
|
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,
|
|
invitation_sent_at=datetime.utcnow(),
|
|
is_active=False, # Will be activated on acceptance
|
|
)
|
|
db.add(store_user)
|
|
db.flush()
|
|
|
|
logger.info(
|
|
f"Invited {email} to store {store.store_code} "
|
|
f"as {role_name} by {inviter.username}"
|
|
)
|
|
|
|
# TODO: Send invitation email
|
|
# self._send_invitation_email(email, store, invitation_token)
|
|
|
|
return {
|
|
"invitation_token": invitation_token,
|
|
"email": email,
|
|
"role": role_name,
|
|
"existing_user": user.is_active,
|
|
}
|
|
|
|
except (TeamMemberAlreadyExistsException, TierLimitExceededException):
|
|
raise
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Error inviting team member: {str(e)}")
|
|
raise
|
|
|
|
def accept_invitation(
|
|
self,
|
|
db: Session,
|
|
invitation_token: str,
|
|
password: str,
|
|
first_name: str | None = None,
|
|
last_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Accept a team invitation and activate account.
|
|
|
|
Args:
|
|
db: Database session
|
|
invitation_token: Invitation token from email
|
|
password: New password to set
|
|
first_name: Optional first name
|
|
last_name: Optional last name
|
|
|
|
Returns:
|
|
Dict with user and store info
|
|
"""
|
|
try:
|
|
# Find invitation
|
|
store_user = (
|
|
db.query(StoreUser)
|
|
.filter(
|
|
StoreUser.invitation_token == invitation_token,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not store_user:
|
|
raise InvalidInvitationTokenException()
|
|
|
|
# Check if already accepted
|
|
if store_user.invitation_accepted_at is not None:
|
|
raise TeamInvitationAlreadyAcceptedException()
|
|
|
|
# Check token expiration (7 days)
|
|
if store_user.invitation_sent_at:
|
|
expiry_date = store_user.invitation_sent_at + timedelta(days=7)
|
|
if datetime.utcnow() > expiry_date:
|
|
raise InvalidInvitationTokenException("Invitation has expired")
|
|
|
|
user = store_user.user
|
|
store = store_user.store
|
|
|
|
# Update user
|
|
user.hashed_password = self.auth_manager.hash_password(password)
|
|
user.is_active = True
|
|
user.is_email_verified = True
|
|
if first_name:
|
|
user.first_name = first_name
|
|
if last_name:
|
|
user.last_name = last_name
|
|
|
|
# Activate membership
|
|
store_user.is_active = True
|
|
store_user.invitation_accepted_at = datetime.utcnow()
|
|
store_user.invitation_token = None # Clear token
|
|
|
|
db.flush()
|
|
|
|
logger.info(
|
|
f"User {user.email} accepted invitation to store {store.store_code}"
|
|
)
|
|
|
|
return {
|
|
"user": user,
|
|
"store": store,
|
|
"role": store_user.role.name if store_user.role else "member",
|
|
}
|
|
|
|
except (
|
|
InvalidInvitationTokenException,
|
|
TeamInvitationAlreadyAcceptedException,
|
|
):
|
|
raise
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Error accepting invitation: {str(e)}")
|
|
raise
|
|
|
|
def remove_team_member(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
user_id: int,
|
|
) -> bool:
|
|
"""
|
|
Remove a team member from a store.
|
|
|
|
Cannot remove owner.
|
|
|
|
Args:
|
|
db: Database session
|
|
store: Store to remove from
|
|
user_id: User ID to remove
|
|
|
|
Returns:
|
|
True if removed
|
|
"""
|
|
try:
|
|
store_user = (
|
|
db.query(StoreUser)
|
|
.filter(
|
|
StoreUser.store_id == store.id,
|
|
StoreUser.user_id == user_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not store_user:
|
|
raise UserNotFoundException(str(user_id))
|
|
|
|
# Cannot remove owner
|
|
if store_user.is_owner:
|
|
raise CannotRemoveOwnerException(user_id, store.id)
|
|
|
|
# Soft delete - just deactivate
|
|
store_user.is_active = False
|
|
|
|
logger.info(f"Removed user {user_id} from store {store.store_code}")
|
|
return True
|
|
|
|
except (UserNotFoundException, CannotRemoveOwnerException):
|
|
raise
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Error removing team member: {str(e)}")
|
|
raise
|
|
|
|
def update_member_role(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
user_id: int,
|
|
new_role_name: str,
|
|
custom_permissions: list[str] | None = None,
|
|
) -> StoreUser:
|
|
"""
|
|
Update a team member's role.
|
|
|
|
Args:
|
|
db: Database session
|
|
store: Store
|
|
user_id: User ID
|
|
new_role_name: New role name
|
|
custom_permissions: Optional custom permissions
|
|
|
|
Returns:
|
|
Updated StoreUser
|
|
"""
|
|
try:
|
|
store_user = (
|
|
db.query(StoreUser)
|
|
.filter(
|
|
StoreUser.store_id == store.id,
|
|
StoreUser.user_id == user_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not store_user:
|
|
raise UserNotFoundException(str(user_id))
|
|
|
|
# Cannot change owner's role
|
|
if store_user.is_owner:
|
|
raise CannotRemoveOwnerException(user_id, store.id)
|
|
|
|
# Get or create new role
|
|
new_role = self._get_or_create_role(
|
|
db=db,
|
|
store=store,
|
|
role_name=new_role_name,
|
|
custom_permissions=custom_permissions,
|
|
)
|
|
|
|
store_user.role_id = new_role.id
|
|
db.flush()
|
|
|
|
logger.info(
|
|
f"Updated role for user {user_id} in store {store.store_code} "
|
|
f"to {new_role_name}"
|
|
)
|
|
|
|
return store_user
|
|
|
|
except (UserNotFoundException, CannotRemoveOwnerException):
|
|
raise
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Error updating member role: {str(e)}")
|
|
raise
|
|
|
|
def get_team_members(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
include_inactive: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Get all team members for a store.
|
|
|
|
Args:
|
|
db: Database session
|
|
store: Store
|
|
include_inactive: Include inactive members
|
|
|
|
Returns:
|
|
List of team member info
|
|
"""
|
|
query = db.query(StoreUser).filter(
|
|
StoreUser.store_id == store.id,
|
|
)
|
|
|
|
if not include_inactive:
|
|
query = query.filter(StoreUser.is_active == True)
|
|
|
|
store_users = query.all()
|
|
|
|
members = []
|
|
for vu in store_users:
|
|
members.append(
|
|
{
|
|
"id": vu.user.id,
|
|
"email": vu.user.email,
|
|
"username": vu.user.username,
|
|
"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(),
|
|
"is_active": vu.is_active,
|
|
"is_owner": vu.is_owner,
|
|
"invitation_pending": vu.is_invitation_pending,
|
|
"invited_at": vu.invitation_sent_at,
|
|
"accepted_at": vu.invitation_accepted_at,
|
|
"joined_at": vu.invitation_accepted_at or vu.created_at or vu.user.created_at,
|
|
}
|
|
)
|
|
|
|
return members
|
|
|
|
def get_store_roles(self, db: Session, store_id: int) -> list[dict[str, Any]]:
|
|
"""
|
|
Get all roles for a store.
|
|
|
|
Creates default preset roles if none exist.
|
|
|
|
Args:
|
|
db: Database session
|
|
store_id: Store ID
|
|
|
|
Returns:
|
|
List of role info dicts
|
|
"""
|
|
roles = db.query(Role).filter(Role.store_id == store_id).all()
|
|
|
|
# Create default roles if none exist
|
|
if not roles:
|
|
default_role_names = ["manager", "staff", "support", "viewer", "marketing"]
|
|
for role_name in default_role_names:
|
|
permissions = list(get_preset_permissions(role_name))
|
|
role = Role(
|
|
store_id=store_id,
|
|
name=role_name,
|
|
permissions=permissions,
|
|
)
|
|
db.add(role) # noqa: PERF006
|
|
db.flush() # Flush to get IDs without committing (endpoint commits)
|
|
roles = db.query(Role).filter(Role.store_id == store_id).all()
|
|
|
|
return [
|
|
{
|
|
"id": role.id,
|
|
"name": role.name,
|
|
"permissions": role.permissions or [],
|
|
"store_id": role.store_id,
|
|
"created_at": role.created_at,
|
|
"updated_at": role.updated_at,
|
|
}
|
|
for role in roles
|
|
]
|
|
|
|
# Private helper methods
|
|
|
|
def _generate_invitation_token(self) -> str:
|
|
"""Generate a secure invitation token."""
|
|
return secrets.token_urlsafe(32)
|
|
|
|
def _get_or_create_role(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
role_name: str,
|
|
custom_permissions: list[str] | None = None,
|
|
) -> Role:
|
|
"""Get existing role or create new one with preset/custom permissions."""
|
|
# Try to find existing role with same name
|
|
role = (
|
|
db.query(Role)
|
|
.filter(
|
|
Role.store_id == store.id,
|
|
Role.name == role_name,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if role and custom_permissions is None:
|
|
# Use existing role
|
|
return role
|
|
|
|
# Determine permissions
|
|
if custom_permissions:
|
|
permissions = custom_permissions
|
|
else:
|
|
# Get preset permissions
|
|
permissions = list(get_preset_permissions(role_name))
|
|
|
|
if role:
|
|
# Update existing role with new permissions
|
|
role.permissions = permissions
|
|
else:
|
|
# Create new role
|
|
role = Role(
|
|
store_id=store.id,
|
|
name=role_name,
|
|
permissions=permissions,
|
|
)
|
|
db.add(role)
|
|
|
|
db.flush()
|
|
return role
|
|
|
|
def _send_invitation_email(self, email: str, store: Store, token: str):
|
|
"""Send invitation email (TODO: implement)."""
|
|
# TODO: Implement email sending
|
|
# Should include:
|
|
# - Link to accept invitation: /store/invitation/accept?token={token}
|
|
# - Store name
|
|
# - Inviter name
|
|
# - Expiry date
|
|
|
|
|
|
# Create service instance
|
|
store_team_service = StoreTeamService()
|