New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View 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