""" 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, TenantHierarchyResponse ) 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.get(route_builder.build_base_route("{tenant_id}/hierarchy", include_tenant_prefix=False), response_model=TenantHierarchyResponse) @track_endpoint_metrics("tenant_hierarchy") async def get_tenant_hierarchy( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """ Get tenant hierarchy information. Returns hierarchy metadata for a tenant including: - Tenant type (standalone, parent, child) - Parent tenant ID (if this is a child) - Hierarchy path (materialized path) - Number of child tenants (for parent tenants) - Hierarchy level (depth in the tree) This endpoint is used by the authentication layer for hierarchical access control and by enterprise features for network management. """ try: logger.info( "Get tenant hierarchy 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" ) # Get tenant from database from app.models.tenants import Tenant async with tenant_service.database_manager.get_session() as session: tenant_repo = TenantRepository(Tenant, session) # Get the tenant tenant = await tenant_repo.get(str(tenant_id)) if not tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tenant {tenant_id} not found" ) # 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 this 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 tenant for hierarchy query", tenant_id=str(tenant_id), user_id=current_user.get("user_id") ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to tenant" ) else: logger.debug( "Service-to-service call - bypassing access check", service=current_user.get("service"), tenant_id=str(tenant_id) ) # Get child count if this is a parent tenant child_count = 0 if tenant.tenant_type in ["parent", "standalone"]: child_count = await tenant_repo.get_child_tenant_count(str(tenant_id)) # Calculate hierarchy level from hierarchy_path hierarchy_level = 0 if tenant.hierarchy_path: # hierarchy_path format: "parent_id" or "parent_id.child_id" or "parent_id.child_id.grandchild_id" hierarchy_level = tenant.hierarchy_path.count('.') # Build response hierarchy_info = TenantHierarchyResponse( tenant_id=str(tenant.id), tenant_type=tenant.tenant_type or "standalone", parent_tenant_id=str(tenant.parent_tenant_id) if tenant.parent_tenant_id else None, hierarchy_path=tenant.hierarchy_path, child_count=child_count, hierarchy_level=hierarchy_level ) logger.info( "Get tenant hierarchy successful", tenant_id=str(tenant_id), tenant_type=tenant.tenant_type, parent_tenant_id=str(tenant.parent_tenant_id) if tenant.parent_tenant_id else None, child_count=child_count, hierarchy_level=hierarchy_level ) return hierarchy_info except HTTPException: raise except Exception as e: logger.error("Get tenant hierarchy 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 hierarchy 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"] )