Vendor team member management features
This commit is contained in:
176
app/api/deps.py
176
app/api/deps.py
@@ -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 []
|
||||
|
||||
|
||||
|
||||
497
app/api/v1/vendor/teams.py
vendored
497
app/api/v1/vendor/teams.py
vendored
@@ -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
203
app/core/permissions.py
Normal 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())
|
||||
@@ -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",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
465
app/services/vendor_team_service.py
Normal file
465
app/services/vendor_team_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user