""" Tenant Operations API - BUSINESS operations Handles complex tenant operations, registration, search, and analytics NOTE: All subscription-related endpoints have been moved to subscription.py as part of the architecture redesign for better separation of concerns. """ import structlog from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, Path, Query from typing import List, Dict, Any, Optional from uuid import UUID from app.schemas.tenants import ( BakeryRegistration, TenantResponse, TenantAccessResponse, TenantSearchRequest ) from app.services.tenant_service import EnhancedTenantService from app.services.payment_service import PaymentService from app.models import AuditLog from shared.auth.decorators import ( get_current_user_dep, require_admin_role_dep ) from app.core.database import get_db from sqlalchemy.ext.asyncio import AsyncSession from shared.auth.access_control import owner_role_required, admin_role_required from shared.routing.route_builder import RouteBuilder from shared.database.base import create_database_manager from shared.monitoring.metrics import track_endpoint_metrics from shared.security import create_audit_logger, AuditSeverity, AuditAction from shared.config.base import is_internal_service logger = structlog.get_logger() router = APIRouter() route_builder = RouteBuilder("tenants") # Initialize audit logger audit_logger = create_audit_logger("tenant-service", AuditLog) # 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") def get_payment_service(): try: return PaymentService() except Exception as e: logger.error("Failed to create payment service", error=str(e)) raise HTTPException(status_code=500, detail="Payment service initialization failed") # ============================================================================ # TENANT REGISTRATION & ACCESS OPERATIONS # ============================================================================ @router.post(route_builder.build_base_route("register", include_tenant_prefix=False), response_model=TenantResponse) async def register_bakery( bakery_data: BakeryRegistration, current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service), payment_service: PaymentService = Depends(get_payment_service), db: AsyncSession = Depends(get_db) ): """Register a new bakery/tenant with enhanced validation and features""" try: coupon_validation = None success = None discount = None error = None result = await tenant_service.create_bakery( bakery_data, current_user["user_id"] ) tenant_id = result.id if bakery_data.link_existing_subscription and bakery_data.subscription_id: logger.info("Linking existing subscription to new tenant", tenant_id=tenant_id, subscription_id=bakery_data.subscription_id, user_id=current_user["user_id"]) try: from app.services.subscription_service import SubscriptionService subscription_service = SubscriptionService(db) linking_result = await subscription_service.link_subscription_to_tenant( subscription_id=bakery_data.subscription_id, tenant_id=tenant_id, user_id=current_user["user_id"] ) logger.info("Subscription linked successfully during tenant registration", tenant_id=tenant_id, subscription_id=bakery_data.subscription_id) except Exception as linking_error: logger.error("Error linking subscription during tenant registration", tenant_id=tenant_id, subscription_id=bakery_data.subscription_id, error=str(linking_error)) elif bakery_data.coupon_code: coupon_validation = payment_service.validate_coupon_code( bakery_data.coupon_code, tenant_id, db ) if not coupon_validation["valid"]: logger.warning( "Invalid coupon code provided during registration", coupon_code=bakery_data.coupon_code, error=coupon_validation["error_message"] ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=coupon_validation["error_message"] ) success, discount, error = payment_service.redeem_coupon( bakery_data.coupon_code, tenant_id, db ) if success: logger.info("Coupon redeemed during registration", coupon_code=bakery_data.coupon_code, tenant_id=tenant_id) else: logger.warning("Failed to redeem coupon during registration", coupon_code=bakery_data.coupon_code, error=error) else: try: from app.repositories.subscription_repository import SubscriptionRepository from app.models.tenants import Subscription from datetime import datetime, timedelta, timezone from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") async with database_manager.get_session() as session: subscription_repo = SubscriptionRepository(Subscription, session) existing_subscription = await subscription_repo.get_by_tenant_id(str(result.id)) if existing_subscription: logger.info( "Tenant already has an active subscription, skipping default subscription creation", tenant_id=str(result.id), existing_plan=existing_subscription.plan, subscription_id=str(existing_subscription.id) ) else: trial_end_date = datetime.now(timezone.utc) next_billing_date = trial_end_date await subscription_repo.create_subscription({ "tenant_id": str(result.id), "plan": "starter", "status": "trial", "billing_cycle": "monthly", "next_billing_date": next_billing_date, "trial_ends_at": trial_end_date }) await session.commit() logger.info( "Default subscription created for new tenant", tenant_id=str(result.id), plan="starter", trial_days=0 ) except Exception as subscription_error: logger.error( "Failed to create default subscription for tenant", tenant_id=str(result.id), error=str(subscription_error) ) if coupon_validation and coupon_validation["valid"]: from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") async with database_manager.get_session() as session: success, discount, error = payment_service.redeem_coupon( bakery_data.coupon_code, result.id, session ) if success: logger.info( "Coupon redeemed successfully", tenant_id=result.id, coupon_code=bakery_data.coupon_code, discount=discount ) else: logger.warning( "Failed to redeem coupon after registration", tenant_id=result.id, coupon_code=bakery_data.coupon_code, error=error ) logger.info("Bakery registered successfully", name=bakery_data.name, owner_email=current_user.get('email'), tenant_id=result.id, coupon_applied=bakery_data.coupon_code is not None) return result except HTTPException: raise except Exception as e: logger.error("Bakery registration failed", name=bakery_data.name, owner_id=current_user["user_id"], error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bakery registration failed" ) @router.get(route_builder.build_base_route("{tenant_id}/my-access", include_tenant_prefix=False), response_model=TenantAccessResponse) async def get_current_user_tenant_access( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """Get current user's access to tenant with role and permissions""" try: from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") tenant_service = EnhancedTenantService(database_manager) access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id)) return access_info except Exception as e: logger.error("Current user access verification failed", user_id=current_user["user_id"], tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access verification failed" ) @router.get(route_builder.build_base_route("{tenant_id}/access/{user_id}", include_tenant_prefix=False), response_model=TenantAccessResponse) async def verify_tenant_access( tenant_id: UUID = Path(..., description="Tenant ID"), user_id: str = Path(..., description="User ID") ): """Verify if user has access to tenant - Enhanced version with detailed permissions""" if is_internal_service(user_id): logger.info("Service access granted", service=user_id, tenant_id=str(tenant_id)) return TenantAccessResponse( has_access=True, role="service", permissions=["read", "write"] ) try: from app.core.config import settings database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service") tenant_service = EnhancedTenantService(database_manager) access_info = await tenant_service.verify_user_access(user_id, str(tenant_id)) return access_info except Exception as e: logger.error("Access verification failed", user_id=user_id, tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access verification failed" ) # ============================================================================ # TENANT SEARCH & DISCOVERY OPERATIONS # ============================================================================ @router.get(route_builder.build_base_route("subdomain/{subdomain}", include_tenant_prefix=False), response_model=TenantResponse) @track_endpoint_metrics("tenant_get_by_subdomain") async def get_tenant_by_subdomain( subdomain: str = Path(..., description="Tenant subdomain"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get tenant by subdomain with enhanced validation""" tenant = await tenant_service.get_tenant_by_subdomain(subdomain) if not tenant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found" ) access = await tenant_service.verify_user_access(current_user["user_id"], tenant.id) if not access.has_access: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to tenant" ) return tenant @router.get(route_builder.build_base_route("user/{user_id}/owned", include_tenant_prefix=False), response_model=List[TenantResponse]) async def get_user_owned_tenants( user_id: str = Path(..., description="User ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get all tenants owned by a user with enhanced data""" user_role = current_user.get('role', '').lower() is_demo_user = current_user.get("is_demo", False) and user_id == "demo-user" if user_id != current_user["user_id"] and not is_demo_user and user_role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only access your own tenants" ) if current_user.get("is_demo", False): demo_session_id = current_user.get("demo_session_id") demo_account_type = current_user.get("demo_account_type", "") if demo_session_id: logger.info("Fetching virtual tenants for demo session", demo_session_id=demo_session_id, demo_account_type=demo_account_type) virtual_tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type) return virtual_tenants else: virtual_tenants = await tenant_service.get_demo_tenants_by_session_type( demo_account_type, str(current_user["user_id"]) ) return virtual_tenants actual_user_id = current_user["user_id"] if is_demo_user else user_id tenants = await tenant_service.get_user_tenants(actual_user_id) return tenants @router.get(route_builder.build_base_route("search", include_tenant_prefix=False), response_model=List[TenantResponse]) @track_endpoint_metrics("tenant_search") async def search_tenants( search_term: str = Query(..., description="Search term"), business_type: Optional[str] = Query(None, description="Business type filter"), city: Optional[str] = Query(None, description="City filter"), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Search tenants with advanced filters and pagination""" tenants = await tenant_service.search_tenants( search_term=search_term, business_type=business_type, city=city, skip=skip, limit=limit ) return tenants @router.get(route_builder.build_base_route("nearby", include_tenant_prefix=False), response_model=List[TenantResponse]) @track_endpoint_metrics("tenant_get_nearby") async def get_nearby_tenants( latitude: float = Query(..., description="Latitude coordinate"), longitude: float = Query(..., description="Longitude coordinate"), radius_km: float = Query(10.0, ge=0.1, le=100.0, description="Search radius in kilometers"), limit: int = Query(50, ge=1, le=100, description="Maximum number of results"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get tenants near a geographic location with enhanced geospatial search""" tenants = await tenant_service.get_tenants_near_location( latitude=latitude, longitude=longitude, radius_km=radius_km, limit=limit ) return tenants @router.get(route_builder.build_base_route("users/{user_id}", include_tenant_prefix=False), response_model=List[TenantResponse]) @track_endpoint_metrics("tenant_get_user_tenants") async def get_user_tenants( user_id: str = Path(..., description="User ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get all tenants owned by a user - Fixed endpoint for frontend""" is_demo_user = current_user.get("is_demo", False) is_service_account = current_user.get("type") == "service" user_role = current_user.get('role', '').lower() if user_id != current_user["user_id"] and not is_service_account and not (is_demo_user and user_id == "demo-user") and user_role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only access your own tenants" ) try: tenants = await tenant_service.get_user_tenants(user_id) logger.info("Retrieved user tenants", user_id=user_id, tenant_count=len(tenants)) return tenants except Exception as e: logger.error("Get user tenants failed", 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(route_builder.build_base_route("members/user/{user_id}", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_get_user_memberships") async def get_user_memberships( user_id: str = Path(..., description="User ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get all tenant memberships for a user (for authentication service)""" is_demo_user = current_user.get("is_demo", False) is_service_account = current_user.get("type") == "service" user_role = current_user.get('role', '').lower() if user_id != current_user["user_id"] and not is_service_account and not (is_demo_user and user_id == "demo-user") and user_role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only access your own memberships" ) try: memberships = await tenant_service.get_user_memberships(user_id) logger.info("Retrieved user memberships", user_id=user_id, membership_count=len(memberships)) return memberships except Exception as e: logger.error("Get user memberships failed", user_id=user_id, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get user memberships" ) # ============================================================================ # TENANT MODEL STATUS OPERATIONS # ============================================================================ @router.put(route_builder.build_base_route("{tenant_id}/model-status", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_update_model_status") async def update_tenant_model_status( tenant_id: UUID = Path(..., description="Tenant ID"), ml_model_trained: bool = Query(..., description="Whether model is trained"), last_training_date: Optional[datetime] = Query(None, description="Last training date"), current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Update tenant model training status with enhanced tracking""" try: result = await tenant_service.update_model_status( str(tenant_id), ml_model_trained, current_user["user_id"], last_training_date ) return result except HTTPException: raise except Exception as e: logger.error("Model status update failed", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update model status" ) # ============================================================================ # TENANT ACTIVATION/DEACTIVATION OPERATIONS # ============================================================================ @router.post(route_builder.build_base_route("{tenant_id}/deactivate", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_deactivate") @owner_role_required async def deactivate_tenant( 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) ): """Deactivate a tenant (owner only) with enhanced validation""" try: success = await tenant_service.deactivate_tenant( str(tenant_id), current_user["user_id"] ) if success: try: from app.core.database import get_db_session async with get_db_session() as db: await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user["user_id"], action=AuditAction.DEACTIVATE.value, resource_type="tenant", resource_id=str(tenant_id), severity=AuditSeverity.CRITICAL.value, description=f"Owner {current_user.get('email', current_user['user_id'])} deactivated tenant", endpoint="/{tenant_id}/deactivate", method="POST" ) except Exception as audit_error: logger.warning("Failed to log audit event", error=str(audit_error)) return {"success": True, "message": "Tenant deactivated successfully"} else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to deactivate tenant" ) except HTTPException: raise except Exception as e: logger.error("Tenant deactivation failed", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to deactivate tenant" ) @router.post(route_builder.build_base_route("{tenant_id}/activate", include_tenant_prefix=False)) @track_endpoint_metrics("tenant_activate") @owner_role_required async def activate_tenant( 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) ): """Activate a previously deactivated tenant (owner only) with enhanced validation""" try: success = await tenant_service.activate_tenant( str(tenant_id), current_user["user_id"] ) if success: try: from app.core.database import get_db_session async with get_db_session() as db: await audit_logger.log_event( db_session=db, tenant_id=str(tenant_id), user_id=current_user["user_id"], action=AuditAction.ACTIVATE.value, resource_type="tenant", resource_id=str(tenant_id), severity=AuditSeverity.HIGH.value, description=f"Owner {current_user.get('email', current_user['user_id'])} activated tenant", endpoint="/{tenant_id}/activate", method="POST" ) except Exception as audit_error: logger.warning("Failed to log audit event", error=str(audit_error)) return {"success": True, "message": "Tenant activated successfully"} else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to activate tenant" ) except HTTPException: raise except Exception as e: logger.error("Tenant activation failed", tenant_id=str(tenant_id), error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to activate tenant" ) # ============================================================================ # TENANT STATISTICS & ANALYTICS # ============================================================================ @router.get(route_builder.build_base_route("statistics", include_tenant_prefix=False), dependencies=[Depends(require_admin_role_dep)]) @track_endpoint_metrics("tenant_get_statistics") async def get_tenant_statistics( current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service) ): """Get comprehensive tenant statistics (admin only) with enhanced analytics""" try: stats = await tenant_service.get_tenant_statistics() return stats except Exception as e: logger.error("Get tenant statistics failed", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get tenant statistics" ) # ============================================================================ # USER-TENANT RELATIONSHIP OPERATIONS # ============================================================================ @router.get(route_builder.build_base_route("users/{user_id}/primary-tenant", include_tenant_prefix=False)) async def get_user_primary_tenant( user_id: str, current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """ Get the primary tenant for a user This endpoint is used by the auth service to validate user subscriptions during login. It returns the user's primary tenant (the one they own or have primary access to). Args: user_id: The user ID to look up Returns: Dictionary with user's primary tenant information, or None if no tenant found Example Response: { "user_id": "user-uuid", "tenant_id": "tenant-uuid", "tenant_name": "Bakery Name", "tenant_type": "standalone", "is_owner": true } """ try: from app.core.database import database_manager from app.repositories.tenant_repository import TenantRepository from app.models.tenants import Tenant async with database_manager.get_session() as session: tenant_repo = TenantRepository(Tenant, session) # Get user's primary tenant (the one they own) primary_tenant = await tenant_repo.get_user_primary_tenant(user_id) if primary_tenant: logger.info("Found primary tenant for user", user_id=user_id, tenant_id=str(primary_tenant.id), tenant_name=primary_tenant.name) return { 'user_id': user_id, 'tenant_id': str(primary_tenant.id), 'tenant_name': primary_tenant.name, 'tenant_type': primary_tenant.tenant_type, 'is_owner': True } else: # If no primary tenant found, check if user has access to any tenant any_tenant = await tenant_repo.get_any_user_tenant(user_id) if any_tenant: logger.info("Found accessible tenant for user", user_id=user_id, tenant_id=str(any_tenant.id), tenant_name=any_tenant.name) return { 'user_id': user_id, 'tenant_id': str(any_tenant.id), 'tenant_name': any_tenant.name, 'tenant_type': any_tenant.tenant_type, 'is_owner': False } else: logger.info("No tenant found for user", user_id=user_id) return None except Exception as e: logger.error(f"Failed to get primary tenant for user {user_id}: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to get primary tenant: {str(e)}")