Some checks failed
New standalone page at /store/{store_code}/invitation/accept?token=xxx
where invited team members can:
- Review their name and email (pre-filled from invitation)
- Set their password
- Accept the invitation
Page handles all routing modes (dev path, platform path, prod subdomain,
custom domain) via store context middleware. After acceptance, redirects
to the platform-aware store login page.
New service method get_invitation_info() validates the token and returns
invitation details without modifying anything.
Error states: expired token, already accepted, invalid token.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1054 lines
33 KiB
Python
1054 lines
33 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.core.services.audit_aggregator import audit_aggregator
|
|
from app.modules.tenancy.exceptions import (
|
|
CannotRemoveOwnerException,
|
|
InvalidInvitationTokenException,
|
|
InvalidRoleException,
|
|
TeamInvitationAlreadyAcceptedException,
|
|
TeamMemberAlreadyExistsException,
|
|
UserNotFoundException,
|
|
)
|
|
from app.modules.tenancy.models import Role, Store, StorePlatform, StoreUser, 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,
|
|
first_name: str | None = None,
|
|
last_name: str | None = None,
|
|
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 (skip if no subscription)
|
|
try:
|
|
from app.modules.billing.services.usage_service import usage_service
|
|
|
|
limit_check = usage_service.check_limit(db, store.id, "team_members")
|
|
if limit_check.limit is not None and not limit_check.can_proceed:
|
|
raise TierLimitExceededException(
|
|
message=limit_check.message or "Team member limit reached",
|
|
limit_type="team_members",
|
|
current=limit_check.current,
|
|
limit=limit_check.limit,
|
|
)
|
|
except TierLimitExceededException:
|
|
raise
|
|
except Exception as e: # noqa: EXC003
|
|
logger.warning(f"Could not check team limit (proceeding): {e}")
|
|
|
|
# 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),
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
role="store_member",
|
|
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,
|
|
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}"
|
|
)
|
|
|
|
try:
|
|
self._send_invitation_email(
|
|
db=db,
|
|
email=email,
|
|
store=store,
|
|
token=invitation_token,
|
|
inviter=inviter,
|
|
role_name=role_name,
|
|
)
|
|
except Exception: # noqa: EXC003
|
|
logger.exception(f"Failed to send invitation email to {email}")
|
|
|
|
audit_aggregator.log(
|
|
db=db,
|
|
admin_user_id=inviter.id,
|
|
action="member.invite",
|
|
target_type="store_user",
|
|
target_id=str(store_user.id),
|
|
details={
|
|
"email": email,
|
|
"role": role_name,
|
|
"store_id": store.id,
|
|
"store_code": store.store_code,
|
|
},
|
|
)
|
|
|
|
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 get_invitation_info(self, db: Session, token: str) -> dict[str, Any] | None:
|
|
"""
|
|
Get invitation details without accepting it.
|
|
|
|
Used by the acceptance page to pre-fill the form.
|
|
Returns None if token is invalid.
|
|
"""
|
|
store_user = (
|
|
db.query(StoreUser)
|
|
.execution_options(include_deleted=True)
|
|
.filter(StoreUser.invitation_token == token)
|
|
.first()
|
|
)
|
|
|
|
if not store_user:
|
|
return None
|
|
|
|
user = store_user.user
|
|
store = store_user.store
|
|
role_name = store_user.role.name if store_user.role else "member"
|
|
|
|
# Check if already accepted
|
|
if store_user.invitation_accepted_at is not None:
|
|
return {
|
|
"valid": False,
|
|
"error": "already_accepted",
|
|
"store_name": store.name,
|
|
}
|
|
|
|
# Check expiration (7 days)
|
|
is_expired = False
|
|
if store_user.invitation_sent_at:
|
|
elapsed = datetime.utcnow() - store_user.invitation_sent_at
|
|
is_expired = elapsed > timedelta(days=7)
|
|
|
|
return {
|
|
"valid": not is_expired,
|
|
"error": "expired" if is_expired else None,
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"store_name": store.name,
|
|
"store_code": store.store_code,
|
|
"role_name": role_name,
|
|
}
|
|
|
|
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,
|
|
actor_user_id: int | None = None,
|
|
) -> 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)
|
|
|
|
from app.core.soft_delete import soft_delete
|
|
|
|
soft_delete(db, store_user, deleted_by_id=actor_user_id)
|
|
|
|
logger.info(f"Removed user {user_id} from store {store.store_code}")
|
|
|
|
if actor_user_id is not None:
|
|
audit_aggregator.log(
|
|
db=db,
|
|
admin_user_id=actor_user_id,
|
|
action="member.remove",
|
|
target_type="store_user",
|
|
target_id=str(store_user.id),
|
|
details={
|
|
"user_id": user_id,
|
|
"store_id": store.id,
|
|
"store_code": 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,
|
|
actor_user_id: int | 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,
|
|
)
|
|
|
|
old_role_name = store_user.role.name if store_user.role else "none"
|
|
store_user.role_id = new_role.id
|
|
db.flush()
|
|
db.refresh(store_user)
|
|
|
|
logger.info(
|
|
f"Updated role for user {user_id} in store {store.store_code} "
|
|
f"to {new_role_name}"
|
|
)
|
|
|
|
if actor_user_id is not None:
|
|
audit_aggregator.log(
|
|
db=db,
|
|
admin_user_id=actor_user_id,
|
|
action="member.role_change",
|
|
target_type="store_user",
|
|
target_id=str(store_user.id),
|
|
details={
|
|
"user_id": user_id,
|
|
"store_id": store.id,
|
|
"old_role": old_role_name,
|
|
"new_role": 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 update_member(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
user_id: int,
|
|
role_id: int | None = None,
|
|
is_active: bool | None = None,
|
|
actor_user_id: int | None = None,
|
|
) -> StoreUser:
|
|
"""
|
|
Update a team member's role (by ID) and/or active status.
|
|
|
|
Args:
|
|
db: Database session
|
|
store: Store
|
|
user_id: User ID
|
|
role_id: New role ID (must belong to this store)
|
|
is_active: New active status
|
|
actor_user_id: Actor performing the update
|
|
|
|
Returns:
|
|
Updated StoreUser
|
|
"""
|
|
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))
|
|
|
|
if role_id is not None:
|
|
role = (
|
|
db.query(Role)
|
|
.filter(Role.id == role_id, Role.store_id == store.id)
|
|
.first()
|
|
)
|
|
if not role:
|
|
raise InvalidRoleException(f"Role {role_id} not found in store {store.id}")
|
|
self.update_member_role(
|
|
db=db,
|
|
store=store,
|
|
user_id=user_id,
|
|
new_role_name=role.name,
|
|
actor_user_id=actor_user_id,
|
|
)
|
|
|
|
if is_active is not None:
|
|
store_user.is_active = is_active
|
|
|
|
db.flush()
|
|
db.refresh(store_user)
|
|
return store_user
|
|
|
|
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,
|
|
"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
|
|
]
|
|
|
|
# ========================================================================
|
|
# Role CRUD
|
|
# ========================================================================
|
|
|
|
PRESET_ROLE_NAMES = {"manager", "staff", "support", "viewer", "marketing"}
|
|
|
|
def create_custom_role(
|
|
self,
|
|
db: Session,
|
|
store_id: int,
|
|
name: str,
|
|
permissions: list[str],
|
|
actor_user_id: int | None = None,
|
|
) -> Role:
|
|
"""
|
|
Create a custom role for a store.
|
|
|
|
Args:
|
|
db: Database session
|
|
store_id: Store ID
|
|
name: Role name
|
|
permissions: List of permission IDs
|
|
actor_user_id: ID of user performing the action (for audit)
|
|
|
|
Returns:
|
|
Created Role object
|
|
|
|
Raises:
|
|
InvalidRoleException: If role name conflicts with a preset
|
|
"""
|
|
if name.lower() in self.PRESET_ROLE_NAMES:
|
|
raise InvalidRoleException(f"Cannot create role with preset name: {name}")
|
|
|
|
# Check for duplicate name
|
|
existing = (
|
|
db.query(Role)
|
|
.filter(Role.store_id == store_id, Role.name == name)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise InvalidRoleException(f"Role '{name}' already exists for this store")
|
|
|
|
# Validate permissions exist
|
|
valid_ids = permission_discovery_service.get_all_permission_ids()
|
|
invalid = set(permissions) - valid_ids
|
|
if invalid:
|
|
raise InvalidRoleException(f"Invalid permission IDs: {', '.join(sorted(invalid))}")
|
|
|
|
role = Role(
|
|
store_id=store_id,
|
|
name=name,
|
|
permissions=permissions,
|
|
)
|
|
db.add(role)
|
|
db.flush()
|
|
|
|
audit_aggregator.log(
|
|
db=db,
|
|
admin_user_id=actor_user_id,
|
|
action="role.create",
|
|
target_type="role",
|
|
target_id=str(role.id),
|
|
details={"name": name, "permissions_count": len(permissions), "store_id": store_id},
|
|
)
|
|
|
|
return role
|
|
|
|
def update_role(
|
|
self,
|
|
db: Session,
|
|
store_id: int,
|
|
role_id: int,
|
|
name: str | None = None,
|
|
permissions: list[str] | None = None,
|
|
actor_user_id: int | None = None,
|
|
) -> Role:
|
|
"""
|
|
Update a role's name and/or permissions.
|
|
|
|
Args:
|
|
db: Database session
|
|
store_id: Store ID (for ownership check)
|
|
role_id: Role ID to update
|
|
name: New role name (optional)
|
|
permissions: New permission list (optional)
|
|
|
|
Returns:
|
|
Updated Role object
|
|
|
|
Raises:
|
|
InvalidRoleException: If role not found or name conflicts
|
|
"""
|
|
role = (
|
|
db.query(Role)
|
|
.filter(Role.id == role_id, Role.store_id == store_id)
|
|
.first()
|
|
)
|
|
if not role:
|
|
raise InvalidRoleException(f"Role {role_id} not found for this store")
|
|
|
|
if name is not None:
|
|
if name.lower() in self.PRESET_ROLE_NAMES and role.name.lower() != name.lower():
|
|
raise InvalidRoleException(f"Cannot rename to preset name: {name}")
|
|
# Check duplicate
|
|
duplicate = (
|
|
db.query(Role)
|
|
.filter(
|
|
Role.store_id == store_id,
|
|
Role.name == name,
|
|
Role.id != role_id,
|
|
)
|
|
.first()
|
|
)
|
|
if duplicate:
|
|
raise InvalidRoleException(f"Role '{name}' already exists for this store")
|
|
role.name = name
|
|
|
|
if permissions is not None:
|
|
valid_ids = permission_discovery_service.get_all_permission_ids()
|
|
invalid = set(permissions) - valid_ids
|
|
if invalid:
|
|
raise InvalidRoleException(
|
|
f"Invalid permission IDs: {', '.join(sorted(invalid))}"
|
|
)
|
|
old_permissions = role.permissions or []
|
|
role.permissions = permissions
|
|
|
|
db.flush()
|
|
|
|
details = {"role_name": role.name, "store_id": store_id}
|
|
if name is not None:
|
|
details["new_name"] = name
|
|
if permissions is not None:
|
|
added = set(permissions) - set(old_permissions)
|
|
removed = set(old_permissions) - set(permissions)
|
|
if added:
|
|
details["permissions_added"] = sorted(added)
|
|
if removed:
|
|
details["permissions_removed"] = sorted(removed)
|
|
|
|
audit_aggregator.log(
|
|
db=db,
|
|
admin_user_id=actor_user_id,
|
|
action="role.update",
|
|
target_type="role",
|
|
target_id=str(role.id),
|
|
details=details,
|
|
)
|
|
|
|
return role
|
|
|
|
def delete_role(
|
|
self,
|
|
db: Session,
|
|
store_id: int,
|
|
role_id: int,
|
|
actor_user_id: int | None = None,
|
|
) -> None:
|
|
"""
|
|
Delete a custom role. Preset roles cannot be deleted.
|
|
|
|
Args:
|
|
db: Database session
|
|
store_id: Store ID (for ownership check)
|
|
role_id: Role ID to delete
|
|
|
|
Raises:
|
|
InvalidRoleException: If role not found or is a preset role
|
|
"""
|
|
role = (
|
|
db.query(Role)
|
|
.filter(Role.id == role_id, Role.store_id == store_id)
|
|
.first()
|
|
)
|
|
if not role:
|
|
raise InvalidRoleException(f"Role {role_id} not found for this store")
|
|
|
|
if role.name.lower() in self.PRESET_ROLE_NAMES:
|
|
raise InvalidRoleException(f"Cannot delete preset role: {role.name}")
|
|
|
|
# Check if any team members use this role
|
|
members_with_role = (
|
|
db.query(StoreUser)
|
|
.filter(StoreUser.store_id == store_id, StoreUser.role_id == role_id)
|
|
.count()
|
|
)
|
|
if members_with_role > 0:
|
|
raise InvalidRoleException(
|
|
f"Cannot delete role: {members_with_role} team member(s) still assigned"
|
|
)
|
|
|
|
role_name = role.name
|
|
db.delete(role)
|
|
db.flush()
|
|
|
|
audit_aggregator.log(
|
|
db=db,
|
|
admin_user_id=actor_user_id,
|
|
action="role.delete",
|
|
target_type="role",
|
|
target_id=str(role_id),
|
|
details={"role_name": role_name, "store_id": store_id},
|
|
)
|
|
|
|
# ========================================================================
|
|
# Admin Access Validation
|
|
# ========================================================================
|
|
|
|
def validate_admin_store_access(
|
|
self,
|
|
db: Session,
|
|
user_context,
|
|
store_id: int,
|
|
) -> Store:
|
|
"""
|
|
Verify an admin user can access the given store.
|
|
|
|
Super admins can access any store. Platform admins can only access
|
|
stores that belong to one of their assigned platforms.
|
|
|
|
Args:
|
|
db: Database session
|
|
user_context: UserContext of the admin user
|
|
store_id: Store ID to validate access to
|
|
|
|
Returns:
|
|
The Store object if access is granted
|
|
|
|
Raises:
|
|
InvalidRoleException: If store not found or admin lacks access
|
|
"""
|
|
store = db.query(Store).filter(Store.id == store_id).first()
|
|
if not store:
|
|
raise InvalidRoleException(f"Store {store_id} not found")
|
|
|
|
# Super admins (accessible_platform_ids is None) can access all stores
|
|
platform_ids = user_context.get_accessible_platform_ids()
|
|
if platform_ids is None:
|
|
return store
|
|
|
|
# Platform admins: store must belong to one of their platforms
|
|
store_in_platform = (
|
|
db.query(StorePlatform)
|
|
.filter(
|
|
StorePlatform.store_id == store_id,
|
|
StorePlatform.platform_id.in_(platform_ids),
|
|
StorePlatform.is_active == True,
|
|
)
|
|
.first()
|
|
)
|
|
if not store_in_platform:
|
|
raise InvalidRoleException(
|
|
"You do not have access to this store's roles"
|
|
)
|
|
|
|
return store
|
|
|
|
# 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 resend_invitation(
|
|
self,
|
|
db: Session,
|
|
store: Store,
|
|
user_id: int,
|
|
inviter: User,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Resend invitation to a pending team member.
|
|
|
|
Generates a new token and resends the email.
|
|
Only works for pending invitations (not yet accepted).
|
|
"""
|
|
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))
|
|
|
|
if store_user.invitation_accepted_at is not None:
|
|
raise InvalidInvitationTokenException("Invitation already accepted")
|
|
|
|
# Generate new token and update sent time
|
|
new_token = self._generate_invitation_token()
|
|
store_user.invitation_token = new_token
|
|
store_user.invitation_sent_at = datetime.utcnow()
|
|
db.flush()
|
|
|
|
# Get role name for email
|
|
role_name = store_user.role.name if store_user.role else "member"
|
|
|
|
# Send email
|
|
try:
|
|
self._send_invitation_email(
|
|
db=db,
|
|
email=store_user.user.email,
|
|
store=store,
|
|
token=new_token,
|
|
inviter=inviter,
|
|
role_name=role_name,
|
|
)
|
|
except Exception: # noqa: EXC003
|
|
logger.exception(
|
|
f"Failed to resend invitation email to {store_user.user.email}"
|
|
)
|
|
|
|
logger.info(
|
|
f"Resent invitation to {store_user.user.email} for store "
|
|
f"{store.store_code} by {inviter.username}"
|
|
)
|
|
|
|
return {
|
|
"email": store_user.user.email,
|
|
"store_code": store.store_code,
|
|
"invitation_sent": True,
|
|
}
|
|
|
|
def _send_invitation_email(
|
|
self,
|
|
db: Session,
|
|
email: str,
|
|
store: Store,
|
|
token: str,
|
|
inviter: User,
|
|
role_name: str,
|
|
):
|
|
"""Send team invitation email."""
|
|
from app.core.config import settings as app_settings
|
|
from app.modules.messaging.services.email_service import EmailService
|
|
|
|
base_url = app_settings.app_base_url.rstrip("/")
|
|
acceptance_link = f"{base_url}/store/{store.store_code}/invitation/accept?token={token}"
|
|
|
|
email_service = EmailService(db)
|
|
email_service.send_template(
|
|
template_code="team_invitation",
|
|
to_email=email,
|
|
variables={
|
|
"invited_by_name": inviter.username,
|
|
"store_name": store.name or store.store_code,
|
|
"role_name": role_name,
|
|
"acceptance_link": acceptance_link,
|
|
"expiry_days": "7",
|
|
},
|
|
store_id=store.id,
|
|
user_id=inviter.id,
|
|
related_type="store_user",
|
|
)
|
|
|
|
|
|
# Create service instance
|
|
store_team_service = StoreTeamService()
|