New enterprise feature
This commit is contained in:
498
services/demo-session/app/services/demo_session_manager.py
Normal file
498
services/demo-session/app/services/demo_session_manager.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
Demo Session Manager for the Demo Session Service
|
||||
Manages temporary demo sessions for different subscription tiers
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from app.models.demo_session import DemoSession, DemoSessionStatus
|
||||
from app.repositories.demo_session_repository import DemoSessionRepository
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DemoSessionManager:
|
||||
"""
|
||||
Manages demo sessions for different subscription tiers
|
||||
"""
|
||||
|
||||
# Demo account configurations
|
||||
DEMO_ACCOUNTS = {
|
||||
"individual_bakery": {
|
||||
"email": "demo.individual@panaderiasanpablo.com",
|
||||
"name": "Panadería San Pablo - Demo Professional",
|
||||
"base_tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
|
||||
"subscription_tier": "professional",
|
||||
"tenant_type": "standalone"
|
||||
},
|
||||
"enterprise_chain": { # NEW
|
||||
"email": "demo.enterprise@panaderiasdeliciosas.com",
|
||||
"name": "Panaderías Deliciosas - Demo Enterprise",
|
||||
"base_tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8",
|
||||
"subscription_tier": "enterprise",
|
||||
"tenant_type": "parent",
|
||||
"children": [
|
||||
{
|
||||
"name": "Outlet Madrid Centro",
|
||||
"base_tenant_id": "d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9",
|
||||
"location": {"city": "Madrid", "zone": "Centro", "lat": 40.4168, "lng": -3.7038}
|
||||
},
|
||||
{
|
||||
"name": "Outlet Barcelona Eixample",
|
||||
"base_tenant_id": "e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0",
|
||||
"location": {"city": "Barcelona", "zone": "Eixample", "lat": 41.3874, "lng": 2.1686}
|
||||
},
|
||||
{
|
||||
"name": "Outlet Valencia Ruzafa",
|
||||
"base_tenant_id": "f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1",
|
||||
"location": {"city": "Valencia", "zone": "Ruzafa", "lat": 39.4699, "lng": -0.3763}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, session_repo: DemoSessionRepository):
|
||||
self.session_repo = session_repo
|
||||
self.settings = settings
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
demo_account_type: str,
|
||||
subscription_tier: str = None # NEW parameter
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new demo session with tier-specific setup
|
||||
|
||||
Args:
|
||||
demo_account_type: Type of demo account ("individual_bakery" or "enterprise_chain")
|
||||
subscription_tier: Force a specific subscription tier (optional)
|
||||
|
||||
Returns:
|
||||
Dict with session information and virtual tenant IDs
|
||||
"""
|
||||
config = self.DEMO_ACCOUNTS.get(demo_account_type)
|
||||
if not config:
|
||||
raise ValueError(f"Unknown demo account type: {demo_account_type}")
|
||||
|
||||
# Generate session ID
|
||||
session_id = f"demo_{secrets.token_urlsafe(16)}"
|
||||
|
||||
# Create virtual tenant ID for parent
|
||||
virtual_tenant_id = uuid.uuid4()
|
||||
|
||||
# For enterprise, generate child tenant IDs
|
||||
child_tenant_ids = []
|
||||
if demo_account_type == "enterprise_chain":
|
||||
child_tenant_ids = [uuid.uuid4() for _ in config["children"]]
|
||||
|
||||
# Create session record
|
||||
session = DemoSession(
|
||||
session_id=session_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
base_demo_tenant_id=uuid.UUID(config["base_tenant_id"]),
|
||||
demo_account_type=demo_account_type,
|
||||
subscription_tier=subscription_tier or config["subscription_tier"],
|
||||
tenant_type=config["tenant_type"],
|
||||
status=DemoSessionStatus.CREATING,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30),
|
||||
metadata={
|
||||
"is_enterprise": demo_account_type == "enterprise_chain",
|
||||
"child_tenant_ids": [str(cid) for cid in child_tenant_ids],
|
||||
"child_configs": config.get("children", []) if demo_account_type == "enterprise_chain" else []
|
||||
}
|
||||
)
|
||||
|
||||
await self.session_repo.create(session)
|
||||
|
||||
# For enterprise demos, set up parent-child relationship and all data
|
||||
if demo_account_type == "enterprise_chain":
|
||||
await self._setup_enterprise_demo(session)
|
||||
else:
|
||||
# For individual bakery, just set up parent tenant
|
||||
await self._setup_individual_demo(session)
|
||||
|
||||
# Update session status to ready
|
||||
session.status = DemoSessionStatus.READY
|
||||
await self.session_repo.update(session)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"virtual_tenant_id": str(virtual_tenant_id),
|
||||
"demo_account_type": demo_account_type,
|
||||
"subscription_tier": session.subscription_tier,
|
||||
"tenant_type": session.tenant_type,
|
||||
"is_enterprise": demo_account_type == "enterprise_chain",
|
||||
"child_tenant_ids": child_tenant_ids if child_tenant_ids else [],
|
||||
"expires_at": session.expires_at.isoformat()
|
||||
}
|
||||
|
||||
async def _setup_individual_demo(self, session: DemoSession):
|
||||
"""Setup individual bakery demo (single tenant)"""
|
||||
try:
|
||||
# Call tenant service to create demo tenant
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.settings.TENANT_SERVICE_URL}/api/v1/tenants/demo/clone",
|
||||
json={
|
||||
"base_tenant_id": str(session.base_demo_tenant_id),
|
||||
"virtual_tenant_id": str(session.virtual_tenant_id),
|
||||
"demo_account_type": session.demo_account_type,
|
||||
"session_id": session.session_id,
|
||||
"subscription_tier": session.subscription_tier
|
||||
},
|
||||
headers={
|
||||
"X-Internal-API-Key": self.settings.INTERNAL_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to create individual demo tenant: {response.text}")
|
||||
raise Exception(f"Failed to create individual demo tenant: {response.text}")
|
||||
|
||||
logger.info(f"Individual demo tenant created: {response.json()}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up individual demo: {e}")
|
||||
session.status = DemoSessionStatus.ERROR
|
||||
await self.session_repo.update(session)
|
||||
raise
|
||||
|
||||
async def _setup_enterprise_demo(self, session: DemoSession):
|
||||
"""Setup enterprise chain demo (parent + multiple child outlets)"""
|
||||
try:
|
||||
logger.info(f"Setting up enterprise demo for session: {session.session_id}")
|
||||
|
||||
# Step 1: Create parent tenant (central production facility)
|
||||
await self._create_enterprise_parent_tenant(session)
|
||||
|
||||
# Step 2: Create all child tenants in parallel
|
||||
await self._create_enterprise_child_tenants(session)
|
||||
|
||||
# Step 3: Setup distribution routes and schedules
|
||||
await self._setup_enterprise_distribution(session)
|
||||
|
||||
logger.info(f"Enterprise demo fully configured for session: {session.session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up enterprise demo: {e}", exc_info=True)
|
||||
session.status = DemoSessionStatus.ERROR
|
||||
await self.session_repo.update(session)
|
||||
raise
|
||||
|
||||
async def _create_enterprise_parent_tenant(self, session: DemoSession):
|
||||
"""Create the parent tenant (central production facility)"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.settings.TENANT_SERVICE_URL}/api/v1/tenants/demo/clone",
|
||||
json={
|
||||
"base_tenant_id": str(session.base_demo_tenant_id),
|
||||
"virtual_tenant_id": str(session.virtual_tenant_id),
|
||||
"demo_account_type": session.demo_account_type,
|
||||
"session_id": session.session_id,
|
||||
"subscription_tier": session.subscription_tier,
|
||||
"tenant_type": "parent", # NEW: Mark as parent
|
||||
"is_enterprise_parent": True
|
||||
},
|
||||
headers={
|
||||
"X-Internal-API-Key": self.settings.INTERNAL_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to create enterprise parent tenant: {response.text}")
|
||||
raise Exception(f"Failed to create enterprise parent tenant: {response.text}")
|
||||
|
||||
logger.info(f"Enterprise parent tenant created: {response.json()}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating enterprise parent tenant: {e}")
|
||||
raise
|
||||
|
||||
async def _create_enterprise_child_tenants(self, session: DemoSession):
|
||||
"""Create all child tenants (retail outlets) in parallel"""
|
||||
try:
|
||||
child_configs = session.metadata.get("child_configs", [])
|
||||
child_tenant_ids = session.metadata.get("child_tenant_ids", [])
|
||||
|
||||
# Create all child tenants in parallel
|
||||
tasks = []
|
||||
for idx, (child_config, child_id) in enumerate(zip(child_configs, child_tenant_ids)):
|
||||
task = self._create_child_outlet_task(
|
||||
base_tenant_id=child_config["base_tenant_id"],
|
||||
virtual_child_id=child_id,
|
||||
parent_tenant_id=str(session.virtual_tenant_id),
|
||||
child_config=child_config,
|
||||
session_id=session.session_id
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Check for errors
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Error creating child tenant {i}: {result}")
|
||||
raise result
|
||||
|
||||
logger.info(f"All {len(child_configs)} child outlets created for session: {session.session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating enterprise child tenants: {e}")
|
||||
raise
|
||||
|
||||
async def _create_child_outlet_task(
|
||||
self,
|
||||
base_tenant_id: str,
|
||||
virtual_child_id: str,
|
||||
parent_tenant_id: str,
|
||||
child_config: Dict[str, Any],
|
||||
session_id: str
|
||||
):
|
||||
"""Task to create a single child outlet"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.settings.TENANT_SERVICE_URL}/api/v1/tenants/demo/create-child",
|
||||
json={
|
||||
"base_tenant_id": base_tenant_id,
|
||||
"virtual_tenant_id": virtual_child_id,
|
||||
"parent_tenant_id": parent_tenant_id,
|
||||
"child_name": child_config["name"],
|
||||
"location": child_config["location"],
|
||||
"session_id": session_id
|
||||
},
|
||||
headers={
|
||||
"X-Internal-API-Key": self.settings.INTERNAL_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to create child outlet {child_config['name']}: {response.text}")
|
||||
raise Exception(f"Failed to create child outlet {child_config['name']}: {response.text}")
|
||||
|
||||
logger.info(f"Child outlet {child_config['name']} created: {response.json()}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating child outlet {child_config['name']}: {e}")
|
||||
raise
|
||||
|
||||
async def _setup_enterprise_distribution(self, session: DemoSession):
|
||||
"""Setup distribution routes and schedules for the enterprise network"""
|
||||
import time
|
||||
max_retries = 3
|
||||
retry_delay = 5 # seconds between retries
|
||||
|
||||
child_tenant_ids = session.metadata.get("child_tenant_ids", [])
|
||||
logger.info(f"Setting up distribution for parent {session.virtual_tenant_id} with {len(child_tenant_ids)} children",
|
||||
session_id=session.session_id, parent_tenant_id=str(session.virtual_tenant_id))
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Verify that tenant data is available before attempting distribution setup
|
||||
await self._verify_tenant_data_availability(str(session.virtual_tenant_id), child_tenant_ids, session.session_id)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.settings.DISTRIBUTION_SERVICE_URL}/internal/demo/setup",
|
||||
json={
|
||||
"parent_tenant_id": str(session.virtual_tenant_id),
|
||||
"child_tenant_ids": child_tenant_ids,
|
||||
"session_id": session.session_id
|
||||
},
|
||||
headers={
|
||||
"X-Internal-API-Key": self.settings.INTERNAL_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_detail = response.text if response.text else f"HTTP {response.status_code}"
|
||||
logger.error(f"Failed to setup enterprise distribution: {error_detail} (attempt {attempt + 1}/{max_retries})")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Retrying distribution setup in {retry_delay}s (attempt {attempt + 1}/{max_retries})")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
raise Exception(f"Failed to setup enterprise distribution: {error_detail}")
|
||||
|
||||
logger.info(f"Enterprise distribution setup completed: {response.json()}")
|
||||
return # Success, exit the retry loop
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
logger.warning(f"Connection timeout setting up enterprise distribution: {e} (attempt {attempt + 1}/{max_retries})")
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Retrying distribution setup in {retry_delay}s")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Connection timeout after {max_retries} attempts: {e}", session_id=session.session_id)
|
||||
raise Exception(f"Connection timeout setting up enterprise distribution: {e}")
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(f"Timeout setting up enterprise distribution: {e} (attempt {attempt + 1}/{max_retries})")
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Retrying distribution setup in {retry_delay}s")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Timeout after {max_retries} attempts: {e}", session_id=session.session_id)
|
||||
raise Exception(f"Timeout setting up enterprise distribution: {e}")
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(f"Request error setting up enterprise distribution: {e} (attempt {attempt + 1}/{max_retries})")
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Retrying distribution setup in {retry_delay}s")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Request error after {max_retries} attempts: {e}", session_id=session.session_id)
|
||||
raise Exception(f"Request error setting up enterprise distribution: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error setting up enterprise distribution: {e}", session_id=session.session_id, exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
async def _verify_tenant_data_availability(self, parent_tenant_id: str, child_tenant_ids: list, session_id: str):
|
||||
"""Verify that tenant data (especially locations) is available before distribution setup"""
|
||||
import time
|
||||
max_retries = 5
|
||||
retry_delay = 2 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Test access to parent tenant locations
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Check if parent tenant exists and has locations
|
||||
parent_response = await client.get(
|
||||
f"{self.settings.TENANT_SERVICE_URL}/api/v1/tenants/{parent_tenant_id}/locations",
|
||||
headers={
|
||||
"X-Internal-API-Key": self.settings.INTERNAL_API_KEY,
|
||||
"X-Demo-Session-Id": session_id
|
||||
}
|
||||
)
|
||||
|
||||
if parent_response.status_code == 200:
|
||||
parent_locations = parent_response.json().get("locations", [])
|
||||
logger.info(f"Parent tenant {parent_tenant_id} has {len(parent_locations)} locations available", session_id=session_id)
|
||||
|
||||
# Check if locations exist before proceeding
|
||||
if parent_locations:
|
||||
# Also quickly check one child tenant if available
|
||||
if child_tenant_ids:
|
||||
child_response = await client.get(
|
||||
f"{self.settings.TENANT_SERVICE_URL}/api/v1/tenants/{child_tenant_ids[0]}/locations",
|
||||
headers={
|
||||
"X-Internal-API-Key": self.settings.INTERNAL_API_KEY,
|
||||
"X-Demo-Session-Id": session_id
|
||||
}
|
||||
)
|
||||
|
||||
if child_response.status_code == 200:
|
||||
child_locations = child_response.json().get("locations", [])
|
||||
logger.info(f"Child tenant {child_tenant_ids[0]} has {len(child_locations)} locations available", session_id=session_id)
|
||||
|
||||
# Both parent and child have location data, proceed
|
||||
return
|
||||
else:
|
||||
logger.warning(f"Child tenant {child_tenant_ids[0]} location data not yet available, attempt {attempt + 1}/{max_retries}",
|
||||
session_id=session_id)
|
||||
else:
|
||||
# No child tenants, but parent has locations, proceed
|
||||
return
|
||||
else:
|
||||
logger.warning(f"Parent tenant {parent_tenant_id} has no location data yet, attempt {attempt + 1}/{max_retries}",
|
||||
session_id=session_id)
|
||||
else:
|
||||
logger.warning(f"Parent tenant {parent_tenant_id} location endpoint not available yet, attempt {attempt + 1}/{max_retries}",
|
||||
session_id=session_id, status_code=parent_response.status_code)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking tenant data availability, attempt {attempt + 1}/{max_retries}: {e}",
|
||||
session_id=session_id)
|
||||
|
||||
# Wait before retrying
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(retry_delay)
|
||||
|
||||
# If we get here, we've exhausted retries
|
||||
logger.warning(f"Tenant data not available after {max_retries} attempts, proceeding anyway", session_id=session_id)
|
||||
|
||||
async def get_session(self, session_id: str) -> Optional[DemoSession]:
|
||||
"""Get a demo session by ID"""
|
||||
return await self.session_repo.get_by_id(session_id)
|
||||
|
||||
async def cleanup_expired_sessions(self) -> int:
|
||||
"""
|
||||
Clean up expired demo sessions
|
||||
|
||||
Returns:
|
||||
Number of sessions cleaned up
|
||||
"""
|
||||
expired_sessions = await self.session_repo.get_expired_sessions()
|
||||
|
||||
cleaned_count = 0
|
||||
for session in expired_sessions:
|
||||
try:
|
||||
# Clean up session data in all relevant services
|
||||
await self._cleanup_session_data(session)
|
||||
|
||||
# Delete session from DB
|
||||
await self.session_repo.delete(session.session_id)
|
||||
cleaned_count += 1
|
||||
|
||||
logger.info(f"Cleaned up expired demo session: {session.session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up session {session.session_id}: {e}")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
async def _cleanup_session_data(self, session: DemoSession):
|
||||
"""Clean up data created for a demo session across all services"""
|
||||
try:
|
||||
# For enterprise demos, clean up parent and all children
|
||||
if session.metadata.get("is_enterprise"):
|
||||
child_tenant_ids = session.metadata.get("child_tenant_ids", [])
|
||||
|
||||
# Clean up children first to avoid foreign key constraint errors
|
||||
for child_id in child_tenant_ids:
|
||||
await self._cleanup_tenant_data(child_id)
|
||||
|
||||
# Then clean up parent
|
||||
await self._cleanup_tenant_data(str(session.virtual_tenant_id))
|
||||
else:
|
||||
# For individual demos, just clean up the tenant
|
||||
await self._cleanup_tenant_data(str(session.virtual_tenant_id))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up session data: {e}")
|
||||
raise
|
||||
|
||||
async def _cleanup_tenant_data(self, tenant_id: str):
|
||||
"""Clean up all data for a specific tenant across all services"""
|
||||
# This would call cleanup endpoints in each service
|
||||
# Implementation depends on each service's cleanup API
|
||||
pass
|
||||
|
||||
async def extend_session(self, session_id: str) -> bool:
|
||||
"""Extend a demo session by 30 minutes"""
|
||||
session = await self.session_repo.get_by_id(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
# Extend by 30 minutes from now
|
||||
session.expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
await self.session_repo.update(session)
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user