543 lines
19 KiB
Python
543 lines
19 KiB
Python
# services/tenant/app/api/tenants.py
|
|
"""
|
|
Tenant API endpoints
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Path
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from typing import List, Dict, Any
|
|
import structlog
|
|
from uuid import UUID
|
|
from sqlalchemy import select, delete, func
|
|
from datetime import datetime
|
|
import uuid
|
|
|
|
from app.core.database import get_db
|
|
from app.services.messaging import publish_tenant_deleted_event
|
|
from app.schemas.tenants import (
|
|
BakeryRegistration, TenantResponse, TenantAccessResponse,
|
|
TenantUpdate, TenantMemberResponse
|
|
)
|
|
from app.services.tenant_service import TenantService
|
|
from shared.auth.decorators import (
|
|
get_current_user_dep,
|
|
require_admin_role
|
|
)
|
|
|
|
logger = structlog.get_logger()
|
|
router = APIRouter()
|
|
|
|
@router.post("/tenants/register", response_model=TenantResponse)
|
|
async def register_bakery(
|
|
bakery_data: BakeryRegistration,
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
|
|
try:
|
|
result = await TenantService.create_bakery(bakery_data, current_user["user_id"], db)
|
|
logger.info(f"Bakery registered: {bakery_data.name} by {current_user['email']}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Bakery registration failed: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Bakery registration failed"
|
|
)
|
|
|
|
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
|
|
async def verify_tenant_access(
|
|
user_id: str,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Verify if user has access to tenant - Called by Gateway"""
|
|
# Check if this is a service request
|
|
if user_id in ["training-service", "data-service", "forecasting-service", "auth-service"]:
|
|
# Services have access to all tenants for their operations
|
|
return TenantAccessResponse(
|
|
has_access=True,
|
|
role="service",
|
|
permissions=["read", "write"]
|
|
)
|
|
|
|
try:
|
|
access_info = await TenantService.verify_user_access(user_id, tenant_id, db)
|
|
return access_info
|
|
|
|
except Exception as e:
|
|
logger.error(f"Access verification failed: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Access verification failed"
|
|
)
|
|
|
|
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
|
async def get_tenant(
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
|
|
# Verify user has access to tenant
|
|
access = await TenantService.verify_user_access(current_user["user_id"], tenant_id, db)
|
|
if not access.has_access:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to tenant"
|
|
)
|
|
|
|
tenant = await TenantService.get_tenant_by_id(tenant_id, db)
|
|
if not tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
return tenant
|
|
|
|
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
|
|
async def update_tenant(
|
|
update_data: TenantUpdate,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
|
|
try:
|
|
result = await TenantService.update_tenant(tenant_id, update_data, current_user["user_id"], db)
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Tenant update failed: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Tenant update failed"
|
|
)
|
|
|
|
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
|
|
async def add_team_member(
|
|
user_id: str,
|
|
role: str,
|
|
tenant_id: UUID = Path(..., description="Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
|
|
try:
|
|
result = await TenantService.add_team_member(
|
|
tenant_id, user_id, role, current_user["user_id"], db
|
|
)
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Add team member failed: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to add team member"
|
|
)
|
|
|
|
@router.delete("/tenants/{tenant_id}")
|
|
async def delete_tenant_complete(
|
|
tenant_id: str,
|
|
current_user = Depends(get_current_user_dep),
|
|
_admin_check = Depends(require_admin_role),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Delete a tenant completely with all associated data.
|
|
|
|
**WARNING: This operation is irreversible!**
|
|
|
|
This endpoint:
|
|
1. Validates tenant exists and user has permissions
|
|
2. Deletes all tenant memberships
|
|
3. Deletes tenant subscription data
|
|
4. Deletes the tenant record
|
|
5. Publishes deletion event
|
|
|
|
Used by admin user deletion process when a tenant has no other admins.
|
|
"""
|
|
|
|
try:
|
|
tenant_uuid = uuid.UUID(tenant_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid tenant ID format"
|
|
)
|
|
|
|
try:
|
|
from app.models.tenants import Tenant, TenantMember, Subscription
|
|
|
|
# Step 1: Verify tenant exists
|
|
tenant_query = select(Tenant).where(Tenant.id == tenant_uuid)
|
|
tenant_result = await db.execute(tenant_query)
|
|
tenant = tenant_result.scalar_one_or_none()
|
|
|
|
if not tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tenant {tenant_id} not found"
|
|
)
|
|
|
|
deletion_stats = {
|
|
"tenant_id": tenant_id,
|
|
"tenant_name": tenant.name,
|
|
"deleted_at": datetime.utcnow().isoformat(),
|
|
"memberships_deleted": 0,
|
|
"subscriptions_deleted": 0,
|
|
"errors": []
|
|
}
|
|
|
|
# Step 2: Delete all tenant memberships
|
|
try:
|
|
membership_count_query = select(func.count(TenantMember.id)).where(
|
|
TenantMember.tenant_id == tenant_uuid
|
|
)
|
|
membership_count_result = await db.execute(membership_count_query)
|
|
membership_count = membership_count_result.scalar()
|
|
|
|
membership_delete_query = delete(TenantMember).where(
|
|
TenantMember.tenant_id == tenant_uuid
|
|
)
|
|
await db.execute(membership_delete_query)
|
|
deletion_stats["memberships_deleted"] = membership_count
|
|
|
|
logger.info("Deleted tenant memberships",
|
|
tenant_id=tenant_id,
|
|
count=membership_count)
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error deleting memberships: {str(e)}"
|
|
deletion_stats["errors"].append(error_msg)
|
|
logger.error(error_msg)
|
|
|
|
# Step 3: Delete subscription data
|
|
try:
|
|
subscription_count_query = select(func.count(Subscription.id)).where(
|
|
Subscription.tenant_id == tenant_uuid
|
|
)
|
|
subscription_count_result = await db.execute(subscription_count_query)
|
|
subscription_count = subscription_count_result.scalar()
|
|
|
|
subscription_delete_query = delete(Subscription).where(
|
|
Subscription.tenant_id == tenant_uuid
|
|
)
|
|
await db.execute(subscription_delete_query)
|
|
deletion_stats["subscriptions_deleted"] = subscription_count
|
|
|
|
logger.info("Deleted tenant subscriptions",
|
|
tenant_id=tenant_id,
|
|
count=subscription_count)
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error deleting subscriptions: {str(e)}"
|
|
deletion_stats["errors"].append(error_msg)
|
|
logger.error(error_msg)
|
|
|
|
# Step 4: Delete the tenant record
|
|
try:
|
|
tenant_delete_query = delete(Tenant).where(Tenant.id == tenant_uuid)
|
|
tenant_result = await db.execute(tenant_delete_query)
|
|
|
|
if tenant_result.rowcount == 0:
|
|
raise Exception("Tenant record was not deleted")
|
|
|
|
await db.commit()
|
|
|
|
logger.info("Tenant deleted successfully",
|
|
tenant_id=tenant_id,
|
|
tenant_name=tenant.name)
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
error_msg = f"Error deleting tenant record: {str(e)}"
|
|
deletion_stats["errors"].append(error_msg)
|
|
logger.error(error_msg)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=error_msg
|
|
)
|
|
|
|
# Step 5: Publish tenant deletion event
|
|
try:
|
|
await publish_tenant_deleted_event(tenant_id, deletion_stats)
|
|
except Exception as e:
|
|
logger.warning("Failed to publish tenant deletion event", error=str(e))
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Tenant {tenant_id} deleted successfully",
|
|
"deletion_details": deletion_stats
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Unexpected error deleting tenant",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete tenant: {str(e)}"
|
|
)
|
|
|
|
@router.get("/user/{user_id}")
|
|
async def get_user_tenants(
|
|
user_id: str,
|
|
current_user = Depends(get_current_user_dep),
|
|
_admin_check = Depends(require_admin_role),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
|
|
"""Get all tenant memberships for a user (admin only)"""
|
|
try:
|
|
user_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid user ID format"
|
|
)
|
|
|
|
try:
|
|
from app.models.tenants import TenantMember, Tenant
|
|
|
|
# Get all memberships for the user
|
|
membership_query = select(TenantMember, Tenant).join(
|
|
Tenant, TenantMember.tenant_id == Tenant.id
|
|
).where(TenantMember.user_id == user_uuid)
|
|
|
|
result = await db.execute(membership_query)
|
|
memberships_data = result.all()
|
|
|
|
memberships = []
|
|
for membership, tenant in memberships_data:
|
|
memberships.append({
|
|
"user_id": str(membership.user_id),
|
|
"tenant_id": str(membership.tenant_id),
|
|
"tenant_name": tenant.name,
|
|
"role": membership.role,
|
|
"joined_at": membership.created_at.isoformat() if membership.created_at else None
|
|
})
|
|
|
|
return {
|
|
"user_id": user_id,
|
|
"total_tenants": len(memberships),
|
|
"memberships": memberships
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get user tenants", user_id=user_id, error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get user tenants"
|
|
)
|
|
|
|
@router.get("/tenants/{tenant_id}/check-other-admins/{user_id}")
|
|
async def check_tenant_has_other_admins(
|
|
tenant_id: str,
|
|
user_id: str,
|
|
current_user = Depends(get_current_user_dep),
|
|
_admin_check = Depends(require_admin_role),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Check if tenant has other admin users besides the specified user"""
|
|
try:
|
|
tenant_uuid = uuid.UUID(tenant_id)
|
|
user_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid UUID format"
|
|
)
|
|
|
|
try:
|
|
from app.models.tenants import TenantMember
|
|
|
|
# Count admin/owner members excluding the specified user
|
|
admin_count_query = select(func.count(TenantMember.id)).where(
|
|
TenantMember.tenant_id == tenant_uuid,
|
|
TenantMember.role.in_(['admin', 'owner']),
|
|
TenantMember.user_id != user_uuid
|
|
)
|
|
|
|
result = await db.execute(admin_count_query)
|
|
admin_count = result.scalar()
|
|
|
|
return {
|
|
"tenant_id": tenant_id,
|
|
"excluded_user_id": user_id,
|
|
"has_other_admins": admin_count > 0,
|
|
"other_admin_count": admin_count
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to check tenant admins",
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to check tenant admins"
|
|
)
|
|
|
|
@router.post("/tenants/{tenant_id}/transfer-ownership")
|
|
async def transfer_tenant_ownership(
|
|
tenant_id: str,
|
|
transfer_data: dict, # {"current_owner_id": str, "new_owner_id": str}
|
|
current_user = Depends(get_current_user_dep),
|
|
_admin_check = Depends(require_admin_role),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Transfer tenant ownership from one user to another (admin only)"""
|
|
try:
|
|
tenant_uuid = uuid.UUID(tenant_id)
|
|
current_owner_id = uuid.UUID(transfer_data.get("current_owner_id"))
|
|
new_owner_id = uuid.UUID(transfer_data.get("new_owner_id"))
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid UUID format in request data"
|
|
)
|
|
|
|
try:
|
|
from app.models.tenants import TenantMember, Tenant
|
|
|
|
# Verify tenant exists
|
|
tenant_query = select(Tenant).where(Tenant.id == tenant_uuid)
|
|
tenant_result = await db.execute(tenant_query)
|
|
tenant = tenant_result.scalar_one_or_none()
|
|
|
|
if not tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tenant {tenant_id} not found"
|
|
)
|
|
|
|
# Get current owner membership
|
|
current_owner_query = select(TenantMember).where(
|
|
TenantMember.tenant_id == tenant_uuid,
|
|
TenantMember.user_id == current_owner_id,
|
|
TenantMember.role == 'owner'
|
|
)
|
|
current_owner_result = await db.execute(current_owner_query)
|
|
current_owner_membership = current_owner_result.scalar_one_or_none()
|
|
|
|
if not current_owner_membership:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Current owner membership not found"
|
|
)
|
|
|
|
# Get new owner membership (should be admin)
|
|
new_owner_query = select(TenantMember).where(
|
|
TenantMember.tenant_id == tenant_uuid,
|
|
TenantMember.user_id == new_owner_id
|
|
)
|
|
new_owner_result = await db.execute(new_owner_query)
|
|
new_owner_membership = new_owner_result.scalar_one_or_none()
|
|
|
|
if not new_owner_membership:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="New owner must be a member of the tenant"
|
|
)
|
|
|
|
if new_owner_membership.role not in ['admin', 'owner']:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="New owner must have admin or owner role"
|
|
)
|
|
|
|
# Perform the transfer
|
|
current_owner_membership.role = 'admin' # Demote current owner to admin
|
|
new_owner_membership.role = 'owner' # Promote new owner
|
|
|
|
current_owner_membership.updated_at = datetime.utcnow()
|
|
new_owner_membership.updated_at = datetime.utcnow()
|
|
|
|
await db.commit()
|
|
|
|
logger.info("Tenant ownership transferred",
|
|
tenant_id=tenant_id,
|
|
from_user=str(current_owner_id),
|
|
to_user=str(new_owner_id))
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Ownership transferred successfully",
|
|
"tenant_id": tenant_id,
|
|
"previous_owner": str(current_owner_id),
|
|
"new_owner": str(new_owner_id),
|
|
"transferred_at": datetime.utcnow().isoformat()
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error("Failed to transfer tenant ownership",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to transfer tenant ownership"
|
|
)
|
|
|
|
@router.delete("/users/{user_id}/memberships")
|
|
async def delete_user_memberships(
|
|
user_id: str,
|
|
current_user = Depends(get_current_user_dep),
|
|
_admin_check = Depends(require_admin_role),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Delete all tenant memberships for a user (admin only)"""
|
|
try:
|
|
user_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid user ID format"
|
|
)
|
|
|
|
try:
|
|
from app.models.tenants import TenantMember
|
|
|
|
# Count memberships before deletion
|
|
count_query = select(func.count(TenantMember.id)).where(
|
|
TenantMember.user_id == user_uuid
|
|
)
|
|
count_result = await db.execute(count_query)
|
|
membership_count = count_result.scalar()
|
|
|
|
# Delete all memberships
|
|
delete_query = delete(TenantMember).where(TenantMember.user_id == user_uuid)
|
|
delete_result = await db.execute(delete_query)
|
|
|
|
await db.commit()
|
|
|
|
logger.info("Deleted user memberships",
|
|
user_id=user_id,
|
|
memberships_deleted=delete_result.rowcount)
|
|
|
|
return {
|
|
"success": True,
|
|
"user_id": user_id,
|
|
"memberships_deleted": delete_result.rowcount,
|
|
"expected_count": membership_count,
|
|
"deleted_at": datetime.utcnow().isoformat()
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error("Failed to delete user memberships", user_id=user_id, error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to delete user memberships"
|
|
) |