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

470 lines
20 KiB
Python
Raw Normal View History

2025-12-05 20:07:01 +01:00
"""
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
2025-12-18 13:26:32 +01:00
from app.schemas.tenants import (
TenantResponse,
ChildTenantCreate,
BulkChildTenantsCreate,
BulkChildTenantsResponse,
ChildTenantResponse
)
2025-12-05 20:07:01 +01:00
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"
)
2025-12-18 13:26:32 +01:00
@router.post(route_builder.build_base_route("bulk-children", include_tenant_prefix=False), response_model=BulkChildTenantsResponse)
@track_endpoint_metrics("bulk_create_child_tenants")
async def bulk_create_child_tenants(
request: BulkChildTenantsCreate,
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=request.parent_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(request.parent_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"],
request.parent_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_tier = await tenant_service.get_subscription_tier(request.parent_tenant_id)
if parent_subscription_tier != "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:
try:
# Create child tenant
child_tenant = Tenant(
name=child_data.name,
subdomain=None, # Child tenants typically don't have subdomains
business_type=parent_tenant.business_type,
business_model="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=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}/{str(parent_tenant.id)}",
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
)
session.add(child_tenant)
await session.flush() # Get the ID without committing
# Create TenantLocation record for the child with location_code
from app.models.tenant_location import TenantLocation
location = TenantLocation(
tenant_id=child_tenant.id,
name=child_data.name,
location_code=child_data.location_code,
city=child_data.city,
zone=child_data.zone,
address=child_data.address,
postal_code=child_data.postal_code,
latitude=child_data.latitude,
longitude=child_data.longitude,
status="ACTIVE",
is_primary=True,
enterprise_location=True,
location_type="retail"
)
session.add(location)
# Inherit subscription from parent
from app.models.tenants import Subscription
parent_subscription = await session.execute(
session.query(Subscription).filter(
Subscription.tenant_id == parent_tenant.id,
Subscription.status == "active"
).statement
)
parent_sub = parent_subscription.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,
price=0, # Child tenants don't pay separately
trial_ends_at=parent_sub.trial_ends_at
)
session.add(child_subscription)
await session.commit()
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=location.location_code,
zone=location.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)
})
await session.rollback()
# 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=request.parent_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=request.parent_tenant_id,
created_count=len(created_tenants),
failed_count=len(failed_tenants)
)
return BulkChildTenantsResponse(
parent_tenant_id=request.parent_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=request.parent_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)}"
)
2025-12-05 20:07:01 +01:00
# 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")
2025-12-18 13:26:32 +01:00
2025-12-05 20:07:01 +01:00
# Include the hierarchy routes with proper tenant prefix
app.include_router(
router,
prefix="/api/v1",
tags=["tenant-hierarchy"]
)