Files
bakery-ia/shared/clients/tenant_client.py
2026-01-15 20:45:49 +01:00

851 lines
33 KiB
Python
Executable File

# shared/clients/tenant_client.py
"""
Tenant Service Client for Inter-Service Communication
This client provides a high-level API for interacting with the Tenant Service,
which manages tenant metadata, settings, hierarchical relationships (parent-child),
and multi-location support for enterprise bakery networks.
Key Capabilities:
- Tenant Management: Get, create, update tenant records
- Settings Management: Category-specific settings (procurement, inventory, production, etc.)
- Enterprise Hierarchy: Parent-child tenant relationships for multi-location networks
- Tenant Locations: Physical location management (central_production, retail_outlet)
- Subscription Management: Subscription tier and quota validation
- Multi-Tenancy: Tenant isolation and access control
Enterprise Hierarchy Features:
- get_child_tenants(): Fetch all child outlets for a parent (central bakery)
- get_parent_tenant(): Get parent tenant from child outlet
- get_tenant_hierarchy(): Get complete hierarchy path and metadata
- get_tenant_locations(): Get all physical locations for a tenant
- Supports 3 tenant types: standalone, parent, child
Usage Example:
```python
from shared.clients import create_tenant_client
from shared.config.base import get_settings
config = get_settings()
client = create_tenant_client(config)
# Get parent tenant and all children
parent = await client.get_tenant(parent_tenant_id)
children = await client.get_child_tenants(parent_tenant_id)
# Get hierarchy information
hierarchy = await client.get_tenant_hierarchy(tenant_id)
# Returns: {tenant_type: 'parent', hierarchy_path: 'parent_id', child_count: 3}
# Get physical locations
locations = await client.get_tenant_locations(parent_tenant_id)
# Returns: [{location_type: 'central_production', ...}, ...]
# Get category settings
procurement_settings = await client.get_procurement_settings(tenant_id)
```
Settings Categories:
- procurement: Min/max order quantities, lead times, reorder points
- inventory: FIFO settings, expiry thresholds, temperature monitoring
- production: Batch sizes, quality control, equipment settings
- supplier: Payment terms, delivery preferences
- pos: POS integration settings
- order: Order fulfillment rules
- notification: Alert preferences
Service Architecture:
- Base URL: Configured via TENANT_SERVICE_URL environment variable
- Authentication: Uses BaseServiceClient with tenant_id header validation
- Error Handling: Returns None on errors, logs detailed error context
- Async: All methods are async and use httpx for HTTP communication
Related Services:
- Distribution Service: Uses tenant locations for delivery route planning
- Forecasting Service: Uses hierarchy for network demand aggregation
- Procurement Service: Validates parent-child for internal transfers
- Orchestrator Service: Enterprise dashboard queries hierarchy data
For more details, see services/tenant/README.md
"""
import structlog
from typing import Dict, Any, Optional, List
from uuid import UUID
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class TenantServiceClient(BaseServiceClient):
"""Client for communicating with the Tenant Service"""
def __init__(self, config: BaseServiceSettings):
super().__init__("tenant", config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# TENANT SETTINGS ENDPOINTS
# ================================================================
async def get_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get all settings for a tenant
Args:
tenant_id: Tenant ID (UUID as string)
Returns:
Dictionary with all settings categories
"""
try:
result = await self.get("settings", tenant_id=tenant_id)
if result:
logger.info("Retrieved all settings from tenant service",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting all settings",
error=str(e), tenant_id=tenant_id)
return None
async def get_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]:
"""
Get settings for a specific category
Args:
tenant_id: Tenant ID (UUID as string)
category: Category name (procurement, inventory, production, supplier, pos, order)
Returns:
Dictionary with category settings
"""
try:
result = await self.get(f"settings/{category}", tenant_id=tenant_id)
if result:
logger.info("Retrieved category settings from tenant service",
tenant_id=tenant_id,
category=category)
return result
except Exception as e:
logger.error("Error getting category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
async def get_procurement_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get procurement settings for a tenant"""
result = await self.get_category_settings(tenant_id, "procurement")
return result.get('settings', {}) if result else {}
async def get_inventory_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get inventory settings for a tenant"""
result = await self.get_category_settings(tenant_id, "inventory")
return result.get('settings', {}) if result else {}
async def get_production_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get production settings for a tenant"""
result = await self.get_category_settings(tenant_id, "production")
return result.get('settings', {}) if result else {}
async def get_supplier_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier settings for a tenant"""
result = await self.get_category_settings(tenant_id, "supplier")
return result.get('settings', {}) if result else {}
async def get_pos_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get POS settings for a tenant"""
result = await self.get_category_settings(tenant_id, "pos")
return result.get('settings', {}) if result else {}
async def get_order_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get order settings for a tenant"""
result = await self.get_category_settings(tenant_id, "order")
return result.get('settings', {}) if result else {}
async def get_notification_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get notification settings for a tenant"""
result = await self.get_category_settings(tenant_id, "notification")
return result.get('settings', {}) if result else {}
async def update_settings(self, tenant_id: str, settings_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Update settings for a tenant
Args:
tenant_id: Tenant ID (UUID as string)
settings_data: Settings data to update
Returns:
Updated settings dictionary
"""
try:
result = await self.put("settings", data=settings_data, tenant_id=tenant_id)
if result:
logger.info("Updated tenant settings",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error updating tenant settings",
error=str(e), tenant_id=tenant_id)
return None
async def update_category_settings(
self,
tenant_id: str,
category: str,
settings_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Update settings for a specific category
Args:
tenant_id: Tenant ID (UUID as string)
category: Category name
settings_data: Settings data to update
Returns:
Updated settings dictionary
"""
try:
result = await self.put(f"settings/{category}", data=settings_data, tenant_id=tenant_id)
if result:
logger.info("Updated category settings",
tenant_id=tenant_id,
category=category)
return result
except Exception as e:
logger.error("Error updating category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
async def reset_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]:
"""
Reset category settings to default values
Args:
tenant_id: Tenant ID (UUID as string)
category: Category name
Returns:
Reset settings dictionary
"""
try:
result = await self.post(f"settings/{category}/reset", data={}, tenant_id=tenant_id)
if result:
logger.info("Reset category settings to defaults",
tenant_id=tenant_id,
category=category)
return result
except Exception as e:
logger.error("Error resetting category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
# ================================================================
# TENANT MANAGEMENT
# ================================================================
async def get_tenant(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get tenant details
Args:
tenant_id: Tenant ID (UUID as string)
Returns:
Tenant data dictionary
"""
try:
# The tenant endpoint is not tenant-scoped, it's a direct path
result = await self._make_request("GET", f"tenants/{tenant_id}")
if result:
logger.info("Retrieved tenant details",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting tenant details",
error=str(e), tenant_id=tenant_id)
return None
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> Optional[list]:
"""
Get all active tenants
Args:
skip: Number of records to skip (pagination)
limit: Maximum number of records to return
Returns:
List of active tenant dictionaries
"""
try:
# Call tenants endpoint (not tenant-scoped)
result = await self._make_request(
"GET",
f"tenants?skip={skip}&limit={limit}"
)
if result:
logger.info("Retrieved active tenants from tenant service",
count=len(result) if isinstance(result, list) else 0)
return result if result else []
except Exception as e:
logger.error(f"Error getting active tenants: {str(e)}")
return []
# ================================================================
# ENTERPRISE TIER METHODS
# ================================================================
async def get_child_tenants(self, parent_tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""
Get all child tenants for a parent tenant
Args:
parent_tenant_id: Parent tenant ID
Returns:
List of child tenant dictionaries
"""
try:
# Use _make_request directly to avoid double tenant_id in URL
# The gateway expects: /api/v1/tenants/{tenant_id}/children
result = await self._make_request("GET", f"tenants/{parent_tenant_id}/children")
if result:
logger.info("Retrieved child tenants",
parent_tenant_id=parent_tenant_id,
child_count=len(result) if isinstance(result, list) else 0)
return result
except Exception as e:
logger.error("Error getting child tenants",
error=str(e), parent_tenant_id=parent_tenant_id)
return None
async def get_tenant_children_count(self, tenant_id: str) -> int:
"""
Get count of child tenants for a parent tenant
Args:
tenant_id: Tenant ID to check
Returns:
Number of child tenants (0 if not a parent)
"""
try:
children = await self.get_child_tenants(tenant_id)
return len(children) if children else 0
except Exception as e:
logger.error("Error getting child tenant count",
error=str(e), tenant_id=tenant_id)
return 0
async def get_parent_tenant(self, child_tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get parent tenant for a child tenant
Args:
child_tenant_id: Child tenant ID
Returns:
Parent tenant dictionary
"""
try:
result = await self.get(f"tenants/{child_tenant_id}/parent", tenant_id=child_tenant_id)
if result:
logger.info("Retrieved parent tenant",
child_tenant_id=child_tenant_id,
parent_tenant_id=result.get('id'))
return result
except Exception as e:
logger.error("Error getting parent tenant",
error=str(e), child_tenant_id=child_tenant_id)
return None
async def get_tenant_hierarchy(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get complete tenant hierarchy information
Args:
tenant_id: Tenant ID to get hierarchy for
Returns:
Hierarchy information dictionary
"""
try:
result = await self.get("hierarchy", tenant_id=tenant_id)
if result:
logger.info("Retrieved tenant hierarchy",
tenant_id=tenant_id,
hierarchy_type=result.get('tenant_type'))
return result
except Exception as e:
logger.error("Error getting tenant hierarchy",
error=str(e), tenant_id=tenant_id)
return None
async def get_tenant_locations(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""
Get all locations for a tenant
Args:
tenant_id: Tenant ID
Returns:
List of tenant location dictionaries
"""
try:
result = await self.get("locations", tenant_id=tenant_id)
if result:
logger.info("Retrieved tenant locations",
tenant_id=tenant_id,
location_count=len(result) if isinstance(result, list) else 0)
return result
except Exception as e:
logger.error("Error getting tenant locations",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if tenant service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error(f"Tenant service health check failed: {str(e)}")
return False
async def get_subscription_status(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get subscription status for a tenant
Args:
tenant_id: Tenant ID
Returns:
Dictionary with subscription status information
"""
try:
result = await self.get(f"tenants/{tenant_id}/subscription/status")
if result:
logger.info("Retrieved subscription status from tenant service",
tenant_id=tenant_id, status=result.get('status'))
return result
except Exception as e:
logger.error("Error getting subscription status",
error=str(e), tenant_id=tenant_id)
return None
async def get_subscription_details(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed subscription information for a tenant
Args:
tenant_id: Tenant ID
Returns:
Dictionary with subscription details
"""
try:
result = await self.get(f"tenants/{tenant_id}/subscription")
if result:
logger.info("Retrieved subscription details from tenant service",
tenant_id=tenant_id, plan=result.get('plan'))
return result
except Exception as e:
logger.error("Error getting subscription details",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# PAYMENT CUSTOMER MANAGEMENT
# ================================================================
async def create_subscription_for_registration(
self,
user_data: Dict[str, Any],
plan_id: str,
payment_method_id: str,
billing_cycle: str = "monthly",
coupon_code: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create a tenant-independent subscription during user registration
This method creates a subscription that is not linked to any tenant yet.
The subscription will be linked to a tenant during the onboarding flow
when the user creates their bakery/tenant.
Args:
user_data: User data including:
- user_id: User ID (required)
- email: User email (required)
- full_name: User full name (required)
- name: User name (optional, defaults to full_name)
plan_id: Subscription plan ID (starter, professional, enterprise)
payment_method_id: Stripe payment method ID
billing_cycle: Billing cycle (monthly or yearly), defaults to monthly
coupon_code: Optional coupon code for discounts/trials
Returns:
Dict with subscription creation results including:
- success: boolean
- subscription_id: string (Stripe subscription ID)
- customer_id: string (Stripe customer ID)
- status: string (subscription status)
- plan: string (plan name)
- billing_cycle: string (billing interval)
- trial_period_days: int (if trial applied)
- coupon_applied: boolean
Returns None if creation fails
"""
try:
logger.info("Creating tenant-independent subscription for registration",
user_id=user_data.get('user_id'),
plan_id=plan_id,
billing_cycle=billing_cycle)
# Prepare data for tenant service
subscription_data = {
"user_data": user_data,
"plan_id": plan_id,
"payment_method_id": payment_method_id,
"billing_interval": billing_cycle,
"coupon_code": coupon_code
}
# Call tenant service endpoint
result = await self.post("tenants/subscriptions/create-for-registration", subscription_data)
if result and result.get("success"):
data = result.get("data", {})
logger.info("Tenant-independent subscription created successfully",
user_id=user_data.get('user_id'),
subscription_id=data.get('subscription_id'),
plan=data.get('plan'))
return data
else:
logger.error("Subscription creation failed via tenant service",
user_id=user_data.get('user_id'),
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to create subscription for registration via tenant service",
user_id=user_data.get('user_id'),
plan_id=plan_id,
error=str(e))
return None
async def link_subscription_to_tenant(
self,
tenant_id: str,
subscription_id: str,
user_id: str
) -> Optional[Dict[str, Any]]:
"""
Link a pending subscription to a tenant
This completes the registration flow by associating the subscription
created during registration with the tenant created during onboarding.
Args:
tenant_id: Tenant ID to link subscription to
subscription_id: Subscription ID (from registration)
user_id: User ID performing the linking (for validation)
Returns:
Dict with linking results:
- success: boolean
- tenant_id: string
- subscription_id: string
- status: string
Returns None if linking fails
"""
try:
logger.info("Linking subscription to tenant",
tenant_id=tenant_id,
subscription_id=subscription_id,
user_id=user_id)
# Prepare data for tenant service
linking_data = {
"subscription_id": subscription_id,
"user_id": user_id
}
# Call tenant service endpoint
result = await self.post(
f"tenants/{tenant_id}/link-subscription",
linking_data
)
if result and result.get("success"):
logger.info("Subscription linked to tenant successfully",
tenant_id=tenant_id,
subscription_id=subscription_id)
return result
else:
logger.error("Subscription linking failed via tenant service",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=result.get('detail') if result else 'No detail provided')
return None
except Exception as e:
logger.error("Failed to link subscription to tenant via tenant service",
tenant_id=tenant_id,
subscription_id=subscription_id,
error=str(e))
return None
async def create_payment_customer(
self,
user_data: Dict[str, Any],
payment_method_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create payment customer (supports pre-user-creation flow)
This method creates a payment customer without requiring a user_id,
supporting the secure architecture where users are only created after
payment verification.
Args:
user_data: User data (email, full_name, etc.)
payment_method_id: Optional payment method ID
Returns:
Dictionary with payment customer creation result
"""
try:
logger.info("Creating payment customer via tenant service",
email=user_data.get('email'),
payment_method_id=payment_method_id)
# Call tenant service endpoint
result = await self.post(
"tenants/payment-customers/create",
{
"user_data": user_data,
"payment_method_id": payment_method_id
}
)
if result and result.get("success"):
logger.info("Payment customer created successfully via tenant service",
email=user_data.get('email'),
payment_customer_id=result.get('payment_customer_id'))
return result
else:
logger.error("Payment customer creation failed via tenant service",
email=user_data.get('email'),
error=result.get('detail') if result else 'No detail provided')
raise Exception("Payment customer creation failed: " +
(result.get('detail') if result else 'Unknown error'))
except Exception as e:
logger.error("Failed to create payment customer via tenant service",
email=user_data.get('email'),
error=str(e))
raise
async def create_registration_payment_setup(
self,
user_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Create registration payment setup via tenant service orchestration
This method calls the tenant service's orchestration endpoint to create
payment customer and SetupIntent in a coordinated workflow.
Args:
user_data: User data including email, full_name, payment_method_id, etc.
Returns:
Dictionary with payment setup results including SetupIntent if required
"""
try:
logger.info("Creating registration payment setup via tenant service orchestration",
email=user_data.get('email'),
payment_method_id=user_data.get('payment_method_id'))
# Call tenant service orchestration endpoint
result = await self.post(
"registration-payment-setup",
user_data
)
if result and result.get("success"):
logger.info("Registration payment setup completed via tenant service orchestration",
email=user_data.get('email'),
requires_action=result.get('requires_action'))
return result
else:
logger.error("Registration payment setup failed via tenant service orchestration",
email=user_data.get('email'),
error=result.get('detail') if result else 'No detail provided')
raise Exception("Registration payment setup failed: " +
(result.get('detail') if result else 'Unknown error'))
except Exception as e:
logger.error("Failed to create registration payment setup via tenant service orchestration",
email=user_data.get('email'),
error=str(e))
raise
async def verify_setup_intent_for_registration(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status via tenant service orchestration
This method calls the tenant service's orchestration endpoint to verify
SetupIntent status before proceeding with user creation.
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
logger.info("Verifying SetupIntent via tenant service orchestration",
setup_intent_id=setup_intent_id)
# Call tenant service orchestration endpoint
result = await self.get(
f"setup-intents/{setup_intent_id}/verify"
)
if result:
logger.info("SetupIntent verification result from tenant service orchestration",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
else:
logger.error("SetupIntent verification failed via tenant service orchestration",
setup_intent_id=setup_intent_id,
error='No result returned')
raise Exception("SetupIntent verification failed: No result returned")
except Exception as e:
logger.error("Failed to verify SetupIntent via tenant service orchestration",
setup_intent_id=setup_intent_id,
error=str(e))
raise
async def verify_setup_intent(
self,
setup_intent_id: str
) -> Dict[str, Any]:
"""
Verify SetupIntent status with payment provider
Args:
setup_intent_id: SetupIntent ID to verify
Returns:
Dictionary with SetupIntent verification result
"""
try:
logger.info("Verifying SetupIntent via tenant service",
setup_intent_id=setup_intent_id)
# Call tenant service endpoint
result = await self.get(
f"tenants/setup-intents/{setup_intent_id}/verify"
)
if result:
logger.info("SetupIntent verification result from tenant service",
setup_intent_id=setup_intent_id,
status=result.get('status'))
return result
else:
logger.error("SetupIntent verification failed via tenant service",
setup_intent_id=setup_intent_id,
error='No result returned')
raise Exception("SetupIntent verification failed: No result returned")
except Exception as e:
logger.error("Failed to verify SetupIntent via tenant service",
setup_intent_id=setup_intent_id,
error=str(e))
raise
async def verify_and_complete_registration(
self,
setup_intent_id: str,
user_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Verify SetupIntent and complete registration via tenant service orchestration
This method calls the tenant service's orchestration endpoint to verify
SetupIntent status and complete the registration process by creating
the user record after successful payment verification.
Args:
setup_intent_id: SetupIntent ID to verify
user_data: User data for registration (email, full_name, etc.)
Returns:
Dictionary with registration completion result including user details
and subscription information
Raises:
Exception: If verification or registration completion fails
"""
try:
logger.info("Verifying SetupIntent and completing registration via tenant service orchestration",
setup_intent_id=setup_intent_id,
email=user_data.get('email'))
# Prepare data for tenant service orchestration
registration_data = {
"setup_intent_id": setup_intent_id,
"user_data": user_data
}
# Call tenant service orchestration endpoint
result = await self.post(
"verify-and-complete-registration",
registration_data
)
if result and result.get("success"):
logger.info("Registration completed successfully via tenant service orchestration",
setup_intent_id=setup_intent_id,
user_id=result.get('user_id'),
email=user_data.get('email'))
return result
else:
logger.error("Registration completion failed via tenant service orchestration",
setup_intent_id=setup_intent_id,
email=user_data.get('email'),
error=result.get('detail') if result else 'No detail provided')
raise Exception("Registration completion failed: " +
(result.get('detail') if result else 'Unknown error'))
except Exception as e:
logger.error("Failed to complete registration via tenant service orchestration",
setup_intent_id=setup_intent_id,
email=user_data.get('email'),
error=str(e))
raise
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:
"""Create tenant service client instance"""
return TenantServiceClient(config)