486 lines
21 KiB
Python
486 lines
21 KiB
Python
"""
|
|
Tenant Hierarchy API - Handles parent-child tenant relationships
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Path
|
|
from typing import List, Dict, Any
|
|
from uuid import UUID
|
|
|
|
from app.schemas.tenants import (
|
|
TenantResponse,
|
|
ChildTenantCreate,
|
|
BulkChildTenantsCreate,
|
|
BulkChildTenantsResponse,
|
|
ChildTenantResponse
|
|
)
|
|
from app.services.tenant_service import EnhancedTenantService
|
|
from app.repositories.tenant_repository import TenantRepository
|
|
from shared.auth.decorators import get_current_user_dep
|
|
from shared.routing.route_builder import RouteBuilder
|
|
from shared.database.base import create_database_manager
|
|
from shared.monitoring.metrics import track_endpoint_metrics
|
|
import structlog
|
|
|
|
logger = structlog.get_logger()
|
|
router = APIRouter()
|
|
route_builder = RouteBuilder("tenants")
|
|
|
|
|
|
# Dependency injection for enhanced tenant service
|
|
def get_enhanced_tenant_service():
|
|
try:
|
|
from app.core.config import settings
|
|
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
|
return EnhancedTenantService(database_manager)
|
|
except Exception as e:
|
|
logger.error("Failed to create enhanced tenant service", error=str(e))
|
|
raise HTTPException(status_code=500, detail="Service initialization failed")
|
|
|
|
|
|
@router.get(route_builder.build_base_route("{tenant_id}/children", include_tenant_prefix=False), response_model=List[TenantResponse])
|
|
@track_endpoint_metrics("tenant_children_list")
|
|
async def get_tenant_children(
|
|
tenant_id: UUID = Path(..., description="Parent Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
|
):
|
|
"""
|
|
Get all child tenants for a parent tenant.
|
|
This endpoint returns all active child tenants associated with the specified parent tenant.
|
|
"""
|
|
try:
|
|
logger.info(
|
|
"Get tenant children request received",
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user.get("user_id"),
|
|
user_type=current_user.get("type", "user"),
|
|
is_service=current_user.get("type") == "service",
|
|
role=current_user.get("role"),
|
|
service_name=current_user.get("service", "none")
|
|
)
|
|
|
|
# Skip access check for service-to-service calls
|
|
is_service_call = current_user.get("type") == "service"
|
|
if not is_service_call:
|
|
# Verify user has access to the parent tenant
|
|
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
|
|
if not access_info.has_access:
|
|
logger.warning(
|
|
"Access denied to parent tenant",
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user.get("user_id")
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to parent tenant"
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Service-to-service call - bypassing access check",
|
|
service=current_user.get("service"),
|
|
tenant_id=str(tenant_id)
|
|
)
|
|
|
|
# Get child tenants from repository
|
|
from app.models.tenants import Tenant
|
|
async with tenant_service.database_manager.get_session() as session:
|
|
tenant_repo = TenantRepository(Tenant, session)
|
|
child_tenants = await tenant_repo.get_child_tenants(str(tenant_id))
|
|
|
|
logger.debug(
|
|
"Get tenant children successful",
|
|
tenant_id=str(tenant_id),
|
|
child_count=len(child_tenants)
|
|
)
|
|
|
|
# Convert to plain dicts while still in session to avoid lazy-load issues
|
|
child_dicts = []
|
|
for child in child_tenants:
|
|
# Handle subscription_tier safely - avoid lazy load
|
|
try:
|
|
# Try to get subscription_tier if subscriptions are already loaded
|
|
sub_tier = child.__dict__.get('_subscription_tier_cache', 'enterprise')
|
|
except:
|
|
sub_tier = 'enterprise' # Default for enterprise children
|
|
|
|
child_dict = {
|
|
'id': str(child.id),
|
|
'name': child.name,
|
|
'subdomain': child.subdomain,
|
|
'business_type': child.business_type,
|
|
'business_model': child.business_model,
|
|
'address': child.address,
|
|
'city': child.city,
|
|
'postal_code': child.postal_code,
|
|
'latitude': child.latitude,
|
|
'longitude': child.longitude,
|
|
'phone': child.phone,
|
|
'email': child.email,
|
|
'timezone': child.timezone,
|
|
'owner_id': str(child.owner_id),
|
|
'parent_tenant_id': str(child.parent_tenant_id) if child.parent_tenant_id else None,
|
|
'tenant_type': child.tenant_type,
|
|
'hierarchy_path': child.hierarchy_path,
|
|
'subscription_tier': sub_tier, # Use the safely retrieved value
|
|
'ml_model_trained': child.ml_model_trained,
|
|
'last_training_date': child.last_training_date,
|
|
'is_active': child.is_active,
|
|
'is_demo': child.is_demo,
|
|
'demo_session_id': child.demo_session_id,
|
|
'created_at': child.created_at,
|
|
'updated_at': child.updated_at
|
|
}
|
|
child_dicts.append(child_dict)
|
|
|
|
# Convert to Pydantic models outside the session without from_attributes
|
|
child_responses = [TenantResponse(**child_dict) for child_dict in child_dicts]
|
|
return child_responses
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Get tenant children failed",
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user.get("user_id"),
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Get tenant children failed"
|
|
)
|
|
|
|
|
|
@router.get(route_builder.build_base_route("{tenant_id}/children/count", include_tenant_prefix=False))
|
|
@track_endpoint_metrics("tenant_children_count")
|
|
async def get_tenant_children_count(
|
|
tenant_id: UUID = Path(..., description="Parent Tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
|
):
|
|
"""
|
|
Get count of child tenants for a parent tenant.
|
|
This endpoint returns the number of active child tenants associated with the specified parent tenant.
|
|
"""
|
|
try:
|
|
logger.info(
|
|
"Get tenant children count request received",
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user.get("user_id")
|
|
)
|
|
|
|
# Skip access check for service-to-service calls
|
|
is_service_call = current_user.get("type") == "service"
|
|
if not is_service_call:
|
|
# Verify user has access to the parent tenant
|
|
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
|
|
if not access_info.has_access:
|
|
logger.warning(
|
|
"Access denied to parent tenant",
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user.get("user_id")
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to parent tenant"
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Service-to-service call - bypassing access check",
|
|
service=current_user.get("service"),
|
|
tenant_id=str(tenant_id)
|
|
)
|
|
|
|
# Get child count from repository
|
|
from app.models.tenants import Tenant
|
|
async with tenant_service.database_manager.get_session() as session:
|
|
tenant_repo = TenantRepository(Tenant, session)
|
|
child_count = await tenant_repo.get_child_tenant_count(str(tenant_id))
|
|
|
|
logger.debug(
|
|
"Get tenant children count successful",
|
|
tenant_id=str(tenant_id),
|
|
child_count=child_count
|
|
)
|
|
|
|
return {
|
|
"parent_tenant_id": str(tenant_id),
|
|
"child_count": child_count
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Get tenant children count failed",
|
|
tenant_id=str(tenant_id),
|
|
user_id=current_user.get("user_id"),
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Get tenant children count failed"
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/tenants/{tenant_id}/bulk-children", response_model=BulkChildTenantsResponse)
|
|
@track_endpoint_metrics("bulk_create_child_tenants")
|
|
async def bulk_create_child_tenants(
|
|
request: BulkChildTenantsCreate,
|
|
tenant_id: str = Path(..., description="Parent tenant ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
|
):
|
|
"""
|
|
Bulk create child tenants for enterprise onboarding.
|
|
|
|
This endpoint creates multiple child tenants (outlets/branches) for an enterprise parent tenant
|
|
and establishes the parent-child relationship. It's designed for use during the onboarding flow
|
|
when an enterprise customer registers their network of locations.
|
|
|
|
Features:
|
|
- Creates child tenants with proper hierarchy
|
|
- Inherits subscription from parent
|
|
- Optionally configures distribution routes
|
|
- Returns detailed success/failure information
|
|
"""
|
|
try:
|
|
logger.info(
|
|
"Bulk child tenant creation request received",
|
|
parent_tenant_id=tenant_id,
|
|
child_count=len(request.child_tenants),
|
|
user_id=current_user.get("user_id")
|
|
)
|
|
|
|
# Verify parent tenant exists and user has access
|
|
async with tenant_service.database_manager.get_session() as session:
|
|
from app.models.tenants import Tenant
|
|
tenant_repo = TenantRepository(Tenant, session)
|
|
|
|
parent_tenant = await tenant_repo.get_by_id(tenant_id)
|
|
if not parent_tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Parent tenant not found"
|
|
)
|
|
|
|
# Verify user has access to parent tenant (owners/admins only)
|
|
access_info = await tenant_service.verify_user_access(
|
|
current_user["user_id"],
|
|
tenant_id
|
|
)
|
|
if not access_info.has_access or access_info.role not in ["owner", "admin"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only tenant owners/admins can create child tenants"
|
|
)
|
|
|
|
# Verify parent is enterprise tier
|
|
parent_subscription = await tenant_service.subscription_repo.get_active_subscription(tenant_id)
|
|
if not parent_subscription or parent_subscription.plan != "enterprise":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Only enterprise tier tenants can have child tenants"
|
|
)
|
|
|
|
# Update parent tenant type if it's still standalone
|
|
if parent_tenant.tenant_type == "standalone":
|
|
parent_tenant.tenant_type = "parent"
|
|
parent_tenant.hierarchy_path = str(parent_tenant.id)
|
|
await session.commit()
|
|
await session.refresh(parent_tenant)
|
|
|
|
# Create child tenants
|
|
created_tenants = []
|
|
failed_tenants = []
|
|
|
|
for child_data in request.child_tenants:
|
|
# Create a nested transaction (savepoint) for each child tenant
|
|
# This allows us to rollback individual child tenant creation without affecting others
|
|
async with session.begin_nested():
|
|
try:
|
|
# Create child tenant with full tenant model fields
|
|
child_tenant = Tenant(
|
|
name=child_data.name,
|
|
subdomain=None, # Child tenants typically don't have subdomains
|
|
business_type=child_data.business_type or parent_tenant.business_type,
|
|
business_model=child_data.business_model or "retail_bakery", # Child outlets are typically retail
|
|
address=child_data.address,
|
|
city=child_data.city,
|
|
postal_code=child_data.postal_code,
|
|
latitude=child_data.latitude,
|
|
longitude=child_data.longitude,
|
|
phone=child_data.phone or parent_tenant.phone,
|
|
email=child_data.email or parent_tenant.email,
|
|
timezone=child_data.timezone or parent_tenant.timezone,
|
|
owner_id=parent_tenant.owner_id,
|
|
parent_tenant_id=parent_tenant.id,
|
|
tenant_type="child",
|
|
hierarchy_path=f"{parent_tenant.hierarchy_path}", # Will be updated after flush
|
|
is_active=True,
|
|
is_demo=parent_tenant.is_demo,
|
|
demo_session_id=parent_tenant.demo_session_id,
|
|
demo_expires_at=parent_tenant.demo_expires_at,
|
|
metadata_={
|
|
"location_code": child_data.location_code,
|
|
"zone": child_data.zone,
|
|
**(child_data.metadata or {})
|
|
}
|
|
)
|
|
|
|
session.add(child_tenant)
|
|
await session.flush() # Get the ID without committing
|
|
|
|
# Update hierarchy_path now that we have the child tenant ID
|
|
child_tenant.hierarchy_path = f"{parent_tenant.hierarchy_path}.{str(child_tenant.id)}"
|
|
|
|
# Create TenantLocation record for the child
|
|
from app.models.tenant_location import TenantLocation
|
|
location = TenantLocation(
|
|
tenant_id=child_tenant.id,
|
|
name=child_data.name,
|
|
city=child_data.city,
|
|
address=child_data.address,
|
|
postal_code=child_data.postal_code,
|
|
latitude=child_data.latitude,
|
|
longitude=child_data.longitude,
|
|
is_active=True,
|
|
location_type="retail"
|
|
)
|
|
session.add(location)
|
|
|
|
# Inherit subscription from parent
|
|
from app.models.tenants import Subscription
|
|
from sqlalchemy import select
|
|
parent_subscription_result = await session.execute(
|
|
select(Subscription).where(
|
|
Subscription.tenant_id == parent_tenant.id,
|
|
Subscription.status == "active"
|
|
)
|
|
)
|
|
parent_sub = parent_subscription_result.scalar_one_or_none()
|
|
|
|
if parent_sub:
|
|
child_subscription = Subscription(
|
|
tenant_id=child_tenant.id,
|
|
plan=parent_sub.plan,
|
|
status="active",
|
|
billing_cycle=parent_sub.billing_cycle,
|
|
monthly_price=0, # Child tenants don't pay separately
|
|
trial_ends_at=parent_sub.trial_ends_at
|
|
)
|
|
session.add(child_subscription)
|
|
|
|
# Commit the nested transaction (savepoint)
|
|
await session.flush()
|
|
|
|
# Refresh objects to get their final state
|
|
await session.refresh(child_tenant)
|
|
await session.refresh(location)
|
|
|
|
# Build response
|
|
created_tenants.append(ChildTenantResponse(
|
|
id=str(child_tenant.id),
|
|
name=child_tenant.name,
|
|
subdomain=child_tenant.subdomain,
|
|
business_type=child_tenant.business_type,
|
|
business_model=child_tenant.business_model,
|
|
tenant_type=child_tenant.tenant_type,
|
|
parent_tenant_id=str(child_tenant.parent_tenant_id),
|
|
address=child_tenant.address,
|
|
city=child_tenant.city,
|
|
postal_code=child_tenant.postal_code,
|
|
phone=child_tenant.phone,
|
|
is_active=child_tenant.is_active,
|
|
subscription_plan="enterprise",
|
|
ml_model_trained=child_tenant.ml_model_trained,
|
|
last_training_date=child_tenant.last_training_date,
|
|
owner_id=str(child_tenant.owner_id),
|
|
created_at=child_tenant.created_at,
|
|
location_code=child_data.location_code,
|
|
zone=child_data.zone,
|
|
hierarchy_path=child_tenant.hierarchy_path
|
|
))
|
|
|
|
logger.info(
|
|
"Child tenant created successfully",
|
|
child_tenant_id=str(child_tenant.id),
|
|
child_name=child_tenant.name,
|
|
location_code=child_data.location_code
|
|
)
|
|
|
|
except Exception as child_error:
|
|
logger.error(
|
|
"Failed to create child tenant",
|
|
child_name=child_data.name,
|
|
error=str(child_error)
|
|
)
|
|
failed_tenants.append({
|
|
"name": child_data.name,
|
|
"location_code": child_data.location_code,
|
|
"error": str(child_error)
|
|
})
|
|
# Nested transaction will automatically rollback on exception
|
|
# This only rolls back the current child tenant, not the entire batch
|
|
|
|
# Commit all successful child tenant creations
|
|
await session.commit()
|
|
|
|
# TODO: Configure distribution routes if requested
|
|
distribution_configured = False
|
|
if request.auto_configure_distribution and len(created_tenants) > 0:
|
|
try:
|
|
# This would call the distribution service to set up routes
|
|
# For now, we'll skip this and just log
|
|
logger.info(
|
|
"Distribution route configuration requested",
|
|
parent_tenant_id=tenant_id,
|
|
child_count=len(created_tenants)
|
|
)
|
|
# distribution_configured = await configure_distribution_routes(...)
|
|
except Exception as dist_error:
|
|
logger.warning(
|
|
"Failed to configure distribution routes",
|
|
error=str(dist_error)
|
|
)
|
|
|
|
logger.info(
|
|
"Bulk child tenant creation completed",
|
|
parent_tenant_id=tenant_id,
|
|
created_count=len(created_tenants),
|
|
failed_count=len(failed_tenants)
|
|
)
|
|
|
|
return BulkChildTenantsResponse(
|
|
parent_tenant_id=tenant_id,
|
|
created_count=len(created_tenants),
|
|
failed_count=len(failed_tenants),
|
|
created_tenants=created_tenants,
|
|
failed_tenants=failed_tenants,
|
|
distribution_configured=distribution_configured
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
"Bulk child tenant creation failed",
|
|
parent_tenant_id=tenant_id,
|
|
user_id=current_user.get("user_id"),
|
|
error=str(e)
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Bulk child tenant creation failed: {str(e)}"
|
|
)
|
|
|
|
|
|
# Register the router in the main app
|
|
def register_hierarchy_routes(app):
|
|
"""Register hierarchy routes with the main application"""
|
|
from shared.routing.route_builder import RouteBuilder
|
|
route_builder = RouteBuilder("tenants")
|
|
|
|
# Include the hierarchy routes with proper tenant prefix
|
|
app.include_router(
|
|
router,
|
|
prefix="/api/v1",
|
|
tags=["tenant-hierarchy"]
|
|
)
|