Files
bakery-ia/services/tenant/app/api/tenants.py

543 lines
19 KiB
Python
Raw Normal View History

2025-07-19 17:49:03 +02:00
# services/tenant/app/api/tenants.py
"""
Tenant API endpoints
"""
2025-07-26 18:46:52 +02:00
from fastapi import APIRouter, Depends, HTTPException, status, Path
2025-07-19 17:49:03 +02:00
from sqlalchemy.ext.asyncio import AsyncSession
2025-07-21 20:48:41 +02:00
from typing import List, Dict, Any
2025-07-19 17:49:03 +02:00
import structlog
2025-07-26 18:46:52 +02:00
from uuid import UUID
2025-08-02 09:41:50 +02:00
from sqlalchemy import select, delete, func
from datetime import datetime
import uuid
2025-07-19 17:49:03 +02:00
from app.core.database import get_db
2025-08-02 09:41:50 +02:00
from app.services.messaging import publish_tenant_deleted_event
2025-07-19 17:49:03 +02:00
from app.schemas.tenants import (
BakeryRegistration, TenantResponse, TenantAccessResponse,
TenantUpdate, TenantMemberResponse
)
from app.services.tenant_service import TenantService
2025-07-21 14:41:33 +02:00
from shared.auth.decorators import (
get_current_user_dep,
2025-08-02 09:41:50 +02:00
require_admin_role
2025-07-21 14:41:33 +02:00
)
2025-07-19 17:49:03 +02:00
logger = structlog.get_logger()
router = APIRouter()
2025-07-20 23:15:57 +02:00
@router.post("/tenants/register", response_model=TenantResponse)
2025-07-19 17:49:03 +02:00
async def register_bakery(
bakery_data: BakeryRegistration,
2025-07-21 14:41:33 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-07-19 17:49:03 +02:00
db: AsyncSession = Depends(get_db)
):
try:
2025-07-21 14:41:33 +02:00
result = await TenantService.create_bakery(bakery_data, current_user["user_id"], db)
logger.info(f"Bakery registered: {bakery_data.name} by {current_user['email']}")
2025-07-19 17:49:03 +02:00
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,
2025-07-26 18:46:52 +02:00
tenant_id: UUID = Path(..., description="Tenant ID"),
2025-07-19 17:49:03 +02:00
db: AsyncSession = Depends(get_db)
):
"""Verify if user has access to tenant - Called by Gateway"""
2025-07-29 12:01:56 +02:00
# Check if this is a service request
2025-08-02 19:09:43 +02:00
if user_id in ["training-service", "data-service", "forecasting-service", "auth-service"]:
2025-07-29 12:01:56 +02:00
# Services have access to all tenants for their operations
return TenantAccessResponse(
has_access=True,
role="service",
permissions=["read", "write"]
)
2025-07-19 17:49:03 +02:00
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(
2025-07-26 18:46:52 +02:00
tenant_id: UUID = Path(..., description="Tenant ID"),
2025-07-21 14:41:33 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-07-19 17:49:03 +02:00
db: AsyncSession = Depends(get_db)
):
2025-07-21 14:41:33 +02:00
2025-07-19 17:49:03 +02:00
# Verify user has access to tenant
2025-07-21 14:41:33 +02:00
access = await TenantService.verify_user_access(current_user["user_id"], tenant_id, db)
2025-07-19 17:49:03 +02:00
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,
2025-07-26 18:46:52 +02:00
tenant_id: UUID = Path(..., description="Tenant ID"),
2025-07-21 14:41:33 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-07-19 17:49:03 +02:00
db: AsyncSession = Depends(get_db)
):
2025-07-21 14:41:33 +02:00
2025-07-19 17:49:03 +02:00
try:
2025-07-21 14:41:33 +02:00
result = await TenantService.update_tenant(tenant_id, update_data, current_user["user_id"], db)
2025-07-19 17:49:03 +02:00
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,
2025-07-26 18:46:52 +02:00
tenant_id: UUID = Path(..., description="Tenant ID"),
2025-07-21 14:41:33 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-07-19 17:49:03 +02:00
db: AsyncSession = Depends(get_db)
):
2025-07-21 14:41:33 +02:00
2025-07-19 17:49:03 +02:00
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"
2025-08-02 09:41:50 +02:00
)
@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)}"
2025-08-02 17:09:53 +02:00
)
2025-08-02 18:38:14 +02:00
@router.get("/user/{user_id}")
2025-08-02 17:09:53 +02:00
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)
):
2025-08-02 19:09:43 +02:00
2025-08-02 17:09:53 +02:00
"""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"
2025-07-19 17:49:03 +02:00
)