Vendor team member management features

This commit is contained in:
2025-11-14 21:08:57 +01:00
parent af23f5b88f
commit 41439eed09
10 changed files with 1786 additions and 85 deletions

View File

@@ -498,3 +498,179 @@ def get_user_vendor(
# User doesn't have access to this vendor
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
# ============================================================================
# PERMISSIONS CHECKING
# ============================================================================
def require_vendor_permission(permission: str):
"""
Dependency factory to require a specific vendor permission.
Usage:
@router.get("/products")
def list_products(
vendor: Vendor = Depends(get_vendor_from_code),
user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value))
):
...
"""
def permission_checker(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
# Get vendor from request state (set by middleware)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Check if user has permission
if not current_user.has_vendor_permission(vendor.id, permission):
raise InsufficientVendorPermissionsException(
required_permission=permission,
vendor_code=vendor.vendor_code,
)
return current_user
return permission_checker
def require_vendor_owner(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
"""
Dependency to require vendor owner role.
Usage:
@router.delete("/team/{user_id}")
def remove_team_member(
user: User = Depends(require_vendor_owner)
):
...
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
if not current_user.is_owner_of(vendor.id):
raise VendorOwnerOnlyException(
operation="team management",
vendor_code=vendor.vendor_code,
)
return current_user
def require_any_vendor_permission(*permissions: str):
"""
Dependency factory to require ANY of the specified permissions.
Usage:
@router.get("/dashboard")
def dashboard(
user: User = Depends(require_any_vendor_permission(
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.REPORTS_VIEW.value
))
):
...
"""
def permission_checker(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Check if user has ANY of the required permissions
has_permission = any(
current_user.has_vendor_permission(vendor.id, perm)
for perm in permissions
)
if not has_permission:
raise InsufficientVendorPermissionsException(
required_permission=f"Any of: {', '.join(permissions)}",
vendor_code=vendor.vendor_code,
)
return current_user
return permission_checker
def require_all_vendor_permissions(*permissions: str):
"""
Dependency factory to require ALL of the specified permissions.
Usage:
@router.post("/products/bulk-delete")
def bulk_delete_products(
user: User = Depends(require_all_vendor_permissions(
VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.PRODUCTS_DELETE.value
))
):
...
"""
def permission_checker(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User:
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorAccessDeniedException("No vendor context")
# Check if user has ALL required permissions
missing_permissions = [
perm for perm in permissions
if not current_user.has_vendor_permission(vendor.id, perm)
]
if missing_permissions:
raise InsufficientVendorPermissionsException(
required_permission=f"All of: {', '.join(permissions)}",
vendor_code=vendor.vendor_code,
)
return current_user
return permission_checker
def get_user_permissions(
request: Request,
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> list:
"""
Get all permissions for current user in current vendor.
Returns empty list if no vendor context.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
return []
# If owner, return all permissions
if current_user.is_owner_of(vendor.id):
from app.core.permissions import VendorPermissions
return [p.value for p in VendorPermissions]
# Get permissions from vendor membership
for vm in current_user.vendor_memberships:
if vm.vendor_id == vendor.id and vm.is_active:
return vm.get_all_permissions()
return []

View File

@@ -1,73 +1,502 @@
# app/api/v1/vendor/teams.py
"""
Vendor team member management endpoints.
Implements complete team management with:
- Team member listing
- Invitation system
- Role management
- Permission checking
- RBAC integration
"""
import logging
from fastapi import APIRouter, Depends
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from middleware.vendor_context import require_vendor_context
from app.services.team_service import team_service
from app.core.permissions import VendorPermissions
from app.api.deps import (
get_current_vendor_from_cookie_or_header,
require_vendor_owner,
require_vendor_permission,
get_user_permissions
)
from app.services.vendor_team_service import vendor_team_service
from models.database.user import User
from models.database.vendor import Vendor
from models.schema.team import (
TeamMemberInvite,
TeamMemberUpdate,
TeamMemberResponse,
TeamMemberListResponse,
InvitationAccept,
InvitationResponse,
InvitationAcceptResponse,
RoleResponse,
RoleListResponse,
UserPermissionsResponse,
TeamStatistics,
BulkRemoveRequest,
BulkRemoveResponse,
)
router = APIRouter(prefix="/teams")
router = APIRouter(prefix="/team")
logger = logging.getLogger(__name__)
@router.get("/members")
def get_team_members(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
# ============================================================================
# Team Member Routes
# ============================================================================
@router.get("/members", response_model=TeamMemberListResponse)
def list_team_members(
request: Request,
include_inactive: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_permission(
VendorPermissions.TEAM_VIEW.value
))
):
"""Get all team members for vendor."""
return team_service.get_team_members(db, vendor.id, current_user)
"""
Get all team members for current vendor.
**Required Permission:** `team.view`
**Query Parameters:**
- `include_inactive`: Include inactive team members (default: False)
**Returns:**
- List of team members with their roles and permissions
- Statistics (total, active, pending)
"""
vendor = request.state.vendor
members = vendor_team_service.get_team_members(
db=db,
vendor=vendor,
include_inactive=include_inactive
)
# Calculate statistics
total = len(members)
active = sum(1 for m in members if m["is_active"])
pending = sum(1 for m in members if m["invitation_pending"])
logger.info(
f"Listed {total} team members for vendor {vendor.vendor_code} "
f"(active: {active}, pending: {pending})"
)
return TeamMemberListResponse(
members=members,
total=total,
active_count=active,
pending_invitations=pending
)
@router.post("/invite")
@router.post("/invite", response_model=InvitationResponse)
def invite_team_member(
invitation_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
invitation: TeamMemberInvite,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_owner) # Owner only
):
"""Invite a new team member."""
return team_service.invite_team_member(db, vendor.id, invitation_data, current_user)
"""
Invite a new team member to the vendor.
**Required:** Vendor owner role
**Process:**
1. Create user account (if doesn't exist)
2. Create VendorUser with invitation token
3. Send invitation email
**Request Body:**
- `email`: Email address of invitee
- `first_name`, `last_name`: Optional names
- `role_name`: Preset role (manager, staff, support, viewer, marketing)
- `role_id`: Use existing role (alternative to role_name)
- `custom_permissions`: Override role permissions (requires role_name)
**Returns:**
- Invitation details
- Confirmation of email sent
"""
vendor = request.state.vendor
# Determine role approach
if invitation.role_id:
# Use existing role by ID
result = vendor_team_service.invite_team_member(
db=db,
vendor=vendor,
inviter=current_user,
email=invitation.email,
role_id=invitation.role_id
)
elif invitation.role_name:
# Use role name with optional custom permissions
result = vendor_team_service.invite_team_member(
db=db,
vendor=vendor,
inviter=current_user,
email=invitation.email,
role_name=invitation.role_name,
custom_permissions=invitation.custom_permissions
)
else:
# Default to Staff role
result = vendor_team_service.invite_team_member(
db=db,
vendor=vendor,
inviter=current_user,
email=invitation.email,
role_name="staff"
)
logger.info(
f"Invitation sent: {invitation.email} to {vendor.vendor_code} "
f"by {current_user.username}"
)
return InvitationResponse(
message="Invitation sent successfully",
email=result["email"],
role=result["role"],
invitation_sent=True
)
@router.put("/members/{user_id}")
@router.post("/accept-invitation", response_model=InvitationAcceptResponse)
def accept_invitation(
acceptance: InvitationAccept,
db: Session = Depends(get_db)
):
"""
Accept a team invitation and activate account.
**No authentication required** - uses invitation token.
**Request Body:**
- `invitation_token`: Token from invitation email
- `password`: New password (min 8 chars, must have upper, lower, digit)
- `first_name`, `last_name`: User's name
**Returns:**
- Confirmation message
- Vendor information
- User information
- Assigned role
"""
result = vendor_team_service.accept_invitation(
db=db,
invitation_token=acceptance.invitation_token,
password=acceptance.password,
first_name=acceptance.first_name,
last_name=acceptance.last_name
)
logger.info(
f"Invitation accepted: {result['user'].email} "
f"for vendor {result['vendor'].vendor_code}"
)
return InvitationAcceptResponse(
message="Invitation accepted successfully. You can now login.",
vendor={
"id": result["vendor"].id,
"vendor_code": result["vendor"].vendor_code,
"name": result["vendor"].name,
"subdomain": result["vendor"].subdomain
},
user={
"id": result["user"].id,
"email": result["user"].email,
"username": result["user"].username,
"full_name": result["user"].full_name
},
role=result["role"]
)
@router.get("/members/{user_id}", response_model=TeamMemberResponse)
def get_team_member(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_permission(
VendorPermissions.TEAM_VIEW.value
))
):
"""
Get details of a specific team member.
**Required Permission:** `team.view`
"""
vendor = request.state.vendor
members = vendor_team_service.get_team_members(
db=db,
vendor=vendor,
include_inactive=True
)
member = next((m for m in members if m["id"] == user_id), None)
if not member:
from app.exceptions import UserNotFoundException
raise UserNotFoundException(str(user_id))
return TeamMemberResponse(**member)
@router.put("/members/{user_id}", response_model=TeamMemberResponse)
def update_team_member(
user_id: int,
update_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
update_data: TeamMemberUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_owner) # Owner only
):
"""Update team member role or status."""
return team_service.update_team_member(db, vendor.id, user_id, update_data, current_user)
"""
Update a team member's role or status.
**Required:** Vendor owner role
**Cannot:**
- Change owner's role
- Remove owner
**Request Body:**
- `role_id`: New role ID (optional)
- `is_active`: Active status (optional)
"""
vendor = request.state.vendor
vendor_user = vendor_team_service.update_member_role(
db=db,
vendor=vendor,
user_id=user_id,
new_role_id=update_data.role_id,
is_active=update_data.is_active
)
logger.info(
f"Team member updated: {user_id} in {vendor.vendor_code} "
f"by {current_user.username}"
)
# Return updated member details
members = vendor_team_service.get_team_members(db, vendor, include_inactive=True)
member = next((m for m in members if m["id"] == user_id), None)
return TeamMemberResponse(**member)
@router.delete("/members/{user_id}")
def remove_team_member(
user_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_owner) # Owner only
):
"""Remove team member from vendor."""
team_service.remove_team_member(db, vendor.id, user_id, current_user)
return {"message": "Team member removed successfully"}
"""
Remove a team member from the vendor.
**Required:** Vendor owner role
**Cannot remove:**
- Vendor owner
**Action:**
- Soft delete (sets is_active = False)
- Member can be re-invited later
"""
vendor = request.state.vendor
vendor_team_service.remove_team_member(
db=db,
vendor=vendor,
user_id=user_id
)
logger.info(
f"Team member removed: {user_id} from {vendor.vendor_code} "
f"by {current_user.username}"
)
return {
"message": "Team member removed successfully",
"user_id": user_id
}
@router.get("/roles")
def get_team_roles(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
@router.post("/members/bulk-remove", response_model=BulkRemoveResponse)
def bulk_remove_team_members(
bulk_remove: BulkRemoveRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_owner)
):
"""Get available roles for vendor team."""
return team_service.get_vendor_roles(db, vendor.id)
"""
Remove multiple team members at once.
**Required:** Vendor owner role
"""
vendor = request.state.vendor
success_count = 0
failed_count = 0
errors = []
for user_id in bulk_remove.user_ids:
try:
vendor_team_service.remove_team_member(
db=db,
vendor=vendor,
user_id=user_id
)
success_count += 1
except Exception as e:
failed_count += 1
errors.append({
"user_id": user_id,
"error": str(e)
})
logger.info(
f"Bulk remove completed: {success_count} removed, {failed_count} failed "
f"in {vendor.vendor_code}"
)
return BulkRemoveResponse(
success_count=success_count,
failed_count=failed_count,
errors=errors
)
# ============================================================================
# Role Management Routes
# ============================================================================
@router.get("/roles", response_model=RoleListResponse)
def list_roles(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_permission(
VendorPermissions.TEAM_VIEW.value
))
):
"""
Get all available roles for the vendor.
**Required Permission:** `team.view`
**Returns:**
- List of roles with permissions
- Includes both preset and custom roles
"""
vendor = request.state.vendor
roles = vendor_team_service.get_vendor_roles(db=db, vendor_id=vendor.id)
return RoleListResponse(
roles=roles,
total=len(roles)
)
# ============================================================================
# Permission Routes
# ============================================================================
@router.get("/me/permissions", response_model=UserPermissionsResponse)
def get_my_permissions(
request: Request,
permissions: List[str] = Depends(get_user_permissions),
current_user: User = Depends(get_current_vendor_from_cookie_or_header)
):
"""
Get current user's permissions in this vendor.
**Use this endpoint to:**
- Determine what UI elements to show/hide
- Check permissions before making API calls
- Display user's role and capabilities
**Returns:**
- Complete list of permissions
- Whether user is owner
- Role name (if team member)
"""
vendor = request.state.vendor
is_owner = current_user.is_owner_of(vendor.id)
role_name = current_user.get_vendor_role(vendor.id)
return UserPermissionsResponse(
permissions=permissions,
permission_count=len(permissions),
is_owner=is_owner,
role_name=role_name
)
# ============================================================================
# Statistics Routes
# ============================================================================
@router.get("/statistics", response_model=TeamStatistics)
def get_team_statistics(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_vendor_permission(
VendorPermissions.TEAM_VIEW.value
))
):
"""
Get team statistics for the vendor.
**Required Permission:** `team.view`
**Returns:**
- Total members
- Active/inactive breakdown
- Pending invitations
- Owner count
- Role distribution
"""
vendor = request.state.vendor
members = vendor_team_service.get_team_members(
db=db,
vendor=vendor,
include_inactive=True
)
# Calculate statistics
total = len(members)
active = sum(1 for m in members if m["is_active"])
inactive = total - active
pending = sum(1 for m in members if m["invitation_pending"])
owners = sum(1 for m in members if m["is_owner"])
team_members = total - owners
# Role breakdown
roles_breakdown = {}
for member in members:
role = member["role_name"]
roles_breakdown[role] = roles_breakdown.get(role, 0) + 1
return TeamStatistics(
total_members=total,
active_members=active,
inactive_members=inactive,
pending_invitations=pending,
owners=owners,
team_members=team_members,
roles_breakdown=roles_breakdown
)

203
app/core/permissions.py Normal file
View File

@@ -0,0 +1,203 @@
# app/core/permissions.py
"""
Permission constants and checking logic for RBAC.
This module defines:
- Vendor-specific permissions
- Permission groups (for easier role creation)
- Permission checking utilities
"""
from enum import Enum
from typing import List, Set
class VendorPermissions(str, Enum):
"""
All available permissions within a vendor context.
Naming convention: RESOURCE_ACTION
"""
# Dashboard
DASHBOARD_VIEW = "dashboard.view"
# Products
PRODUCTS_VIEW = "products.view"
PRODUCTS_CREATE = "products.create"
PRODUCTS_EDIT = "products.edit"
PRODUCTS_DELETE = "products.delete"
PRODUCTS_IMPORT = "products.import"
PRODUCTS_EXPORT = "products.export"
# Stock/Inventory
STOCK_VIEW = "stock.view"
STOCK_EDIT = "stock.edit"
STOCK_TRANSFER = "stock.transfer"
# Orders
ORDERS_VIEW = "orders.view"
ORDERS_EDIT = "orders.edit"
ORDERS_CANCEL = "orders.cancel"
ORDERS_REFUND = "orders.refund"
# Customers
CUSTOMERS_VIEW = "customers.view"
CUSTOMERS_EDIT = "customers.edit"
CUSTOMERS_DELETE = "customers.delete"
CUSTOMERS_EXPORT = "customers.export"
# Marketing
MARKETING_VIEW = "marketing.view"
MARKETING_CREATE = "marketing.create"
MARKETING_SEND = "marketing.send"
# Reports
REPORTS_VIEW = "reports.view"
REPORTS_FINANCIAL = "reports.financial"
REPORTS_EXPORT = "reports.export"
# Settings
SETTINGS_VIEW = "settings.view"
SETTINGS_EDIT = "settings.edit"
SETTINGS_THEME = "settings.theme"
SETTINGS_DOMAINS = "settings.domains"
# Team Management
TEAM_VIEW = "team.view"
TEAM_INVITE = "team.invite"
TEAM_EDIT = "team.edit"
TEAM_REMOVE = "team.remove"
# Marketplace Imports
IMPORTS_VIEW = "imports.view"
IMPORTS_CREATE = "imports.create"
IMPORTS_CANCEL = "imports.cancel"
class PermissionGroups:
"""Pre-defined permission groups for common roles."""
# Full access (for owners)
OWNER: Set[str] = set(p.value for p in VendorPermissions)
# Manager - Can do most things except team management and critical settings
MANAGER: Set[str] = {
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.PRODUCTS_CREATE.value,
VendorPermissions.PRODUCTS_EDIT.value,
VendorPermissions.PRODUCTS_DELETE.value,
VendorPermissions.PRODUCTS_IMPORT.value,
VendorPermissions.PRODUCTS_EXPORT.value,
VendorPermissions.STOCK_VIEW.value,
VendorPermissions.STOCK_EDIT.value,
VendorPermissions.STOCK_TRANSFER.value,
VendorPermissions.ORDERS_VIEW.value,
VendorPermissions.ORDERS_EDIT.value,
VendorPermissions.ORDERS_CANCEL.value,
VendorPermissions.ORDERS_REFUND.value,
VendorPermissions.CUSTOMERS_VIEW.value,
VendorPermissions.CUSTOMERS_EDIT.value,
VendorPermissions.CUSTOMERS_EXPORT.value,
VendorPermissions.MARKETING_VIEW.value,
VendorPermissions.MARKETING_CREATE.value,
VendorPermissions.MARKETING_SEND.value,
VendorPermissions.REPORTS_VIEW.value,
VendorPermissions.REPORTS_FINANCIAL.value,
VendorPermissions.REPORTS_EXPORT.value,
VendorPermissions.SETTINGS_VIEW.value,
VendorPermissions.SETTINGS_THEME.value,
VendorPermissions.IMPORTS_VIEW.value,
VendorPermissions.IMPORTS_CREATE.value,
VendorPermissions.IMPORTS_CANCEL.value,
}
# Staff - Can view and edit products/orders but limited access
STAFF: Set[str] = {
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.PRODUCTS_CREATE.value,
VendorPermissions.PRODUCTS_EDIT.value,
VendorPermissions.STOCK_VIEW.value,
VendorPermissions.STOCK_EDIT.value,
VendorPermissions.ORDERS_VIEW.value,
VendorPermissions.ORDERS_EDIT.value,
VendorPermissions.CUSTOMERS_VIEW.value,
VendorPermissions.CUSTOMERS_EDIT.value,
}
# Support - Can view and assist with orders/customers
SUPPORT: Set[str] = {
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.ORDERS_VIEW.value,
VendorPermissions.ORDERS_EDIT.value,
VendorPermissions.CUSTOMERS_VIEW.value,
VendorPermissions.CUSTOMERS_EDIT.value,
}
# Viewer - Read-only access
VIEWER: Set[str] = {
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.STOCK_VIEW.value,
VendorPermissions.ORDERS_VIEW.value,
VendorPermissions.CUSTOMERS_VIEW.value,
VendorPermissions.REPORTS_VIEW.value,
}
# Marketing - Focused on marketing and customer communication
MARKETING: Set[str] = {
VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.CUSTOMERS_VIEW.value,
VendorPermissions.CUSTOMERS_EXPORT.value,
VendorPermissions.MARKETING_VIEW.value,
VendorPermissions.MARKETING_CREATE.value,
VendorPermissions.MARKETING_SEND.value,
VendorPermissions.REPORTS_VIEW.value,
}
class PermissionChecker:
"""Utility class for permission checking."""
@staticmethod
def has_permission(permissions: List[str], required_permission: str) -> bool:
"""Check if a permission list contains a required permission."""
return required_permission in permissions
@staticmethod
def has_any_permission(permissions: List[str], required_permissions: List[str]) -> bool:
"""Check if a permission list contains ANY of the required permissions."""
return any(perm in permissions for perm in required_permissions)
@staticmethod
def has_all_permissions(permissions: List[str], required_permissions: List[str]) -> bool:
"""Check if a permission list contains ALL of the required permissions."""
return all(perm in permissions for perm in required_permissions)
@staticmethod
def get_missing_permissions(permissions: List[str], required_permissions: List[str]) -> List[str]:
"""Get list of missing permissions."""
return [perm for perm in required_permissions if perm not in permissions]
# Helper function to get permissions for a role preset
def get_preset_permissions(preset_name: str) -> Set[str]:
"""
Get permissions for a preset role.
Args:
preset_name: Name of the preset (manager, staff, support, viewer, marketing)
Returns:
Set of permission strings
"""
presets = {
"owner": PermissionGroups.OWNER,
"manager": PermissionGroups.MANAGER,
"staff": PermissionGroups.STAFF,
"support": PermissionGroups.SUPPORT,
"viewer": PermissionGroups.VIEWER,
"marketing": PermissionGroups.MARKETING,
}
return presets.get(preset_name.lower(), set())

View File

@@ -146,6 +146,7 @@ from .team import (
MaxTeamMembersReachedException,
TeamValidationException,
InvalidInvitationDataException,
InvalidInvitationTokenException,
)
# Product exceptions
@@ -215,6 +216,7 @@ __all__ = [
"MaxTeamMembersReachedException",
"TeamValidationException",
"InvalidInvitationDataException",
"InvalidInvitationTokenException",
# Inventory exceptions
"InventoryNotFoundException",
@@ -306,4 +308,4 @@ __all__ = [
"InvalidAdminActionException",
"BulkOperationException",
"ConfirmationRequiredException",
]
]

View File

@@ -147,10 +147,10 @@ class InvalidRoleException(ValidationException):
"""Raised when role data is invalid."""
def __init__(
self,
message: str = "Invalid role data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
self,
message: str = "Invalid role data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
@@ -164,10 +164,10 @@ class InsufficientPermissionsException(AuthorizationException):
"""Raised when user lacks required permissions for an action."""
def __init__(
self,
required_permission: str,
user_id: Optional[int] = None,
action: Optional[str] = None,
self,
required_permission: str,
user_id: Optional[int] = None,
action: Optional[str] = None,
):
details = {"required_permission": required_permission}
if user_id:
@@ -202,10 +202,10 @@ class TeamValidationException(ValidationException):
"""Raised when team operation validation fails."""
def __init__(
self,
message: str = "Team operation validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
self,
message: str = "Team operation validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
):
details = {}
if validation_errors:
@@ -223,10 +223,10 @@ class InvalidInvitationDataException(ValidationException):
"""Raised when team invitation data is invalid."""
def __init__(
self,
message: str = "Invalid invitation data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
self,
message: str = "Invalid invitation data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
@@ -234,3 +234,31 @@ class InvalidInvitationDataException(ValidationException):
details=details,
)
self.error_code = "INVALID_INVITATION_DATA"
# ============================================================================
# NEW: Add InvalidInvitationTokenException
# ============================================================================
class InvalidInvitationTokenException(ValidationException):
"""Raised when invitation token is invalid, expired, or already used.
This is a general exception for any invitation token validation failure.
Use this when checking invitation tokens during the acceptance flow.
"""
def __init__(
self,
message: str = "Invalid or expired invitation token",
invitation_token: Optional[str] = None
):
details = {}
if invitation_token:
details["invitation_token"] = invitation_token
super().__init__(
message=message,
field="invitation_token",
details=details,
)
self.error_code = "INVALID_INVITATION_TOKEN"

View File

@@ -0,0 +1,465 @@
# app/services/vendor_team_service.py
"""
Vendor 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 Optional, List, Dict, Any
from sqlalchemy.orm import Session
from app.core.permissions import get_preset_permissions
from app.exceptions import (
TeamMemberAlreadyExistsException,
InvalidInvitationTokenException,
TeamInvitationAlreadyAcceptedException,
MaxTeamMembersReachedException,
UserNotFoundException,
VendorNotFoundException,
CannotRemoveOwnerException,
)
from models.database.user import User
from models.database.vendor import Vendor, VendorUser, VendorUserType, Role
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
class VendorTeamService:
"""Service for managing vendor team members."""
def __init__(self):
self.auth_manager = AuthManager()
self.max_team_members = 50 # Configure as needed
def invite_team_member(
self,
db: Session,
vendor: Vendor,
inviter: User,
email: str,
role_name: str,
custom_permissions: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Invite a new team member to a vendor.
Creates:
1. User account (if doesn't exist)
2. Role (if custom permissions provided)
3. VendorUser relationship with invitation token
Args:
db: Database session
vendor: Vendor 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
current_team_size = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
).count()
if current_team_size >= self.max_team_members:
raise MaxTeamMembersReachedException(
self.max_team_members,
vendor.vendor_code,
)
# 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(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user.id,
).first()
if existing_membership:
if existing_membership.is_active:
raise TeamMemberAlreadyExistsException(email, vendor.vendor_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.commit()
logger.info(f"Re-invited user {email} to vendor {vendor.vendor_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="vendor", # 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,
vendor=vendor,
role_name=role_name,
custom_permissions=custom_permissions,
)
# Create vendor membership with invitation
invitation_token = self._generate_invitation_token()
vendor_user = VendorUser(
vendor_id=vendor.id,
user_id=user.id,
user_type=VendorUserType.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(vendor_user)
db.commit()
logger.info(
f"Invited {email} to vendor {vendor.vendor_code} "
f"as {role_name} by {inviter.username}"
)
# TODO: Send invitation email
# self._send_invitation_email(email, vendor, invitation_token)
return {
"invitation_token": invitation_token,
"email": email,
"role": role_name,
"existing_user": user.is_active,
}
except (TeamMemberAlreadyExistsException, MaxTeamMembersReachedException):
raise
except Exception as e:
db.rollback()
logger.error(f"Error inviting team member: {str(e)}")
raise
def accept_invitation(
self,
db: Session,
invitation_token: str,
password: str,
first_name: Optional[str] = None,
last_name: Optional[str] = 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 vendor info
"""
try:
# Find invitation
vendor_user = db.query(VendorUser).filter(
VendorUser.invitation_token == invitation_token,
).first()
if not vendor_user:
raise InvalidInvitationTokenException()
# Check if already accepted
if vendor_user.invitation_accepted_at is not None:
raise TeamInvitationAlreadyAcceptedException()
# Check token expiration (7 days)
if vendor_user.invitation_sent_at:
expiry_date = vendor_user.invitation_sent_at + timedelta(days=7)
if datetime.utcnow() > expiry_date:
raise InvalidInvitationTokenException("Invitation has expired")
user = vendor_user.user
vendor = vendor_user.vendor
# 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
vendor_user.is_active = True
vendor_user.invitation_accepted_at = datetime.utcnow()
vendor_user.invitation_token = None # Clear token
db.commit()
logger.info(
f"User {user.email} accepted invitation to vendor {vendor.vendor_code}"
)
return {
"user": user,
"vendor": vendor,
"role": vendor_user.role.name if vendor_user.role else "member",
}
except (InvalidInvitationTokenException, TeamInvitationAlreadyAcceptedException):
raise
except Exception as e:
db.rollback()
logger.error(f"Error accepting invitation: {str(e)}")
raise
def remove_team_member(
self,
db: Session,
vendor: Vendor,
user_id: int,
) -> bool:
"""
Remove a team member from a vendor.
Cannot remove owner.
Args:
db: Database session
vendor: Vendor to remove from
user_id: User ID to remove
Returns:
True if removed
"""
try:
vendor_user = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user_id,
).first()
if not vendor_user:
raise UserNotFoundException(str(user_id))
# Cannot remove owner
if vendor_user.is_owner:
raise CannotRemoveOwnerException(vendor.vendor_code)
# Soft delete - just deactivate
vendor_user.is_active = False
db.commit()
logger.info(f"Removed user {user_id} from vendor {vendor.vendor_code}")
return True
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except Exception as e:
db.rollback()
logger.error(f"Error removing team member: {str(e)}")
raise
def update_member_role(
self,
db: Session,
vendor: Vendor,
user_id: int,
new_role_name: str,
custom_permissions: Optional[List[str]] = None,
) -> VendorUser:
"""
Update a team member's role.
Args:
db: Database session
vendor: Vendor
user_id: User ID
new_role_name: New role name
custom_permissions: Optional custom permissions
Returns:
Updated VendorUser
"""
try:
vendor_user = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
VendorUser.user_id == user_id,
).first()
if not vendor_user:
raise UserNotFoundException(str(user_id))
# Cannot change owner's role
if vendor_user.is_owner:
raise CannotRemoveOwnerException(vendor.vendor_code)
# Get or create new role
new_role = self._get_or_create_role(
db=db,
vendor=vendor,
role_name=new_role_name,
custom_permissions=custom_permissions,
)
vendor_user.role_id = new_role.id
db.commit()
logger.info(
f"Updated role for user {user_id} in vendor {vendor.vendor_code} "
f"to {new_role_name}"
)
return vendor_user
except (UserNotFoundException, CannotRemoveOwnerException):
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating member role: {str(e)}")
raise
def get_team_members(
self,
db: Session,
vendor: Vendor,
include_inactive: bool = False,
) -> List[Dict[str, Any]]:
"""
Get all team members for a vendor.
Args:
db: Database session
vendor: Vendor
include_inactive: Include inactive members
Returns:
List of team member info
"""
query = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor.id,
)
if not include_inactive:
query = query.filter(VendorUser.is_active == True)
vendor_users = query.all()
members = []
for vu in vendor_users:
members.append({
"id": vu.user.id,
"email": vu.user.email,
"username": vu.user.username,
"full_name": vu.user.full_name,
"user_type": vu.user_type,
"role": vu.role.name if vu.role else "owner",
"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,
})
return members
# 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,
vendor: Vendor,
role_name: str,
custom_permissions: Optional[List[str]] = 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.vendor_id == vendor.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(
vendor_id=vendor.id,
name=role_name,
permissions=permissions,
)
db.add(role)
db.flush()
return role
def _send_invitation_email(self, email: str, vendor: Vendor, token: str):
"""Send invitation email (TODO: implement)."""
# TODO: Implement email sending
# Should include:
# - Link to accept invitation: /vendor/invitation/accept?token={token}
# - Vendor name
# - Inviter name
# - Expiry date
pass
# Create service instance
vendor_team_service = VendorTeamService()