833 lines
32 KiB
Python
833 lines
32 KiB
Python
"""
|
|
Internal Demo Cloning API
|
|
Service-to-service endpoint for cloning tenant data
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
import structlog
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Optional
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from app.core.database import get_db
|
|
from app.models.tenants import Tenant, Subscription, TenantMember
|
|
from app.models.tenant_location import TenantLocation
|
|
from shared.utils.demo_dates import adjust_date_for_demo, resolve_time_marker
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = structlog.get_logger()
|
|
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
|
|
|
# Base demo tenant IDs
|
|
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
|
|
|
|
|
def parse_date_field(
|
|
field_value: any,
|
|
session_time: datetime,
|
|
field_name: str = "date"
|
|
) -> Optional[datetime]:
|
|
"""
|
|
Parse a date field from JSON, supporting BASE_TS markers and ISO timestamps.
|
|
|
|
Args:
|
|
field_value: The date field value (can be BASE_TS marker, ISO string, or None)
|
|
session_time: Session creation time (timezone-aware UTC)
|
|
field_name: Name of the field (for logging)
|
|
|
|
Returns:
|
|
Timezone-aware UTC datetime or None
|
|
"""
|
|
if field_value is None:
|
|
return None
|
|
|
|
# Handle BASE_TS markers
|
|
if isinstance(field_value, str) and field_value.startswith("BASE_TS"):
|
|
try:
|
|
return resolve_time_marker(field_value, session_time)
|
|
except (ValueError, AttributeError) as e:
|
|
logger.warning(
|
|
"Failed to resolve BASE_TS marker",
|
|
field_name=field_name,
|
|
marker=field_value,
|
|
error=str(e)
|
|
)
|
|
return None
|
|
|
|
# Handle ISO timestamps (legacy format - convert to absolute datetime)
|
|
if isinstance(field_value, str) and ('T' in field_value or 'Z' in field_value):
|
|
try:
|
|
parsed_date = datetime.fromisoformat(field_value.replace('Z', '+00:00'))
|
|
# Adjust relative to session time
|
|
return adjust_date_for_demo(parsed_date, session_time)
|
|
except (ValueError, AttributeError) as e:
|
|
logger.warning(
|
|
"Failed to parse ISO timestamp",
|
|
field_name=field_name,
|
|
value=field_value,
|
|
error=str(e)
|
|
)
|
|
return None
|
|
|
|
logger.warning(
|
|
"Unknown date format",
|
|
field_name=field_name,
|
|
value=field_value,
|
|
value_type=type(field_value).__name__
|
|
)
|
|
return None
|
|
|
|
|
|
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
|
"""Verify internal API key for service-to-service communication"""
|
|
if x_internal_api_key != settings.INTERNAL_API_KEY:
|
|
logger.warning("Unauthorized internal API access attempted")
|
|
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
|
return True
|
|
|
|
|
|
@router.post("/clone")
|
|
async def clone_demo_data(
|
|
base_tenant_id: str,
|
|
virtual_tenant_id: str,
|
|
demo_account_type: str,
|
|
session_id: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(verify_internal_api_key)
|
|
):
|
|
"""
|
|
Clone tenant service data for a virtual demo tenant
|
|
|
|
This endpoint creates the virtual tenant record that will be used
|
|
for the demo session. No actual data cloning is needed in tenant service
|
|
beyond creating the tenant record itself.
|
|
|
|
Args:
|
|
base_tenant_id: Template tenant UUID (not used, for consistency)
|
|
virtual_tenant_id: Target virtual tenant UUID
|
|
demo_account_type: Type of demo account
|
|
session_id: Originating session ID for tracing
|
|
|
|
Returns:
|
|
Cloning status and record count
|
|
"""
|
|
start_time = datetime.now(timezone.utc)
|
|
|
|
logger.info(
|
|
"Starting tenant data cloning",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
demo_account_type=demo_account_type,
|
|
session_id=session_id
|
|
)
|
|
|
|
try:
|
|
# Validate UUIDs
|
|
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
|
|
|
# Check if tenant already exists
|
|
result = await db.execute(
|
|
select(Tenant).where(Tenant.id == virtual_uuid)
|
|
)
|
|
existing_tenant = result.scalars().first()
|
|
|
|
if existing_tenant:
|
|
logger.info(
|
|
"Virtual tenant already exists",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
tenant_name=existing_tenant.name
|
|
)
|
|
|
|
# Ensure the tenant has a subscription (copy from template if missing)
|
|
from datetime import timedelta
|
|
|
|
result = await db.execute(
|
|
select(Subscription).where(
|
|
Subscription.tenant_id == virtual_uuid,
|
|
Subscription.status == "active"
|
|
)
|
|
)
|
|
existing_subscription = result.scalars().first()
|
|
|
|
if not existing_subscription:
|
|
logger.info("Creating missing subscription for existing virtual tenant by copying from template",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
base_tenant_id=base_tenant_id)
|
|
|
|
# Load subscription from seed data instead of cloning from template
|
|
try:
|
|
from shared.utils.seed_data_paths import get_seed_data_path
|
|
|
|
if demo_account_type == "professional":
|
|
json_file = get_seed_data_path("professional", "01-tenant.json")
|
|
elif demo_account_type == "enterprise":
|
|
json_file = get_seed_data_path("enterprise", "01-tenant.json")
|
|
else:
|
|
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
|
|
|
except ImportError:
|
|
# Fallback to original path
|
|
seed_data_dir = Path(__file__).parent.parent.parent.parent / "infrastructure" / "seed-data"
|
|
if demo_account_type == "professional":
|
|
json_file = seed_data_dir / "professional" / "01-tenant.json"
|
|
elif demo_account_type == "enterprise":
|
|
json_file = seed_data_dir / "enterprise" / "parent" / "01-tenant.json"
|
|
else:
|
|
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
|
|
|
if json_file.exists():
|
|
import json
|
|
with open(json_file, 'r', encoding='utf-8') as f:
|
|
seed_data = json.load(f)
|
|
|
|
subscription_data = seed_data.get('subscription')
|
|
if subscription_data:
|
|
# Load subscription from seed data
|
|
subscription = Subscription(
|
|
tenant_id=virtual_uuid,
|
|
plan=subscription_data.get('plan', 'professional'),
|
|
status=subscription_data.get('status', 'active'),
|
|
monthly_price=subscription_data.get('monthly_price', 299.00),
|
|
billing_cycle=subscription_data.get('billing_cycle', 'monthly'),
|
|
max_users=subscription_data.get('max_users', 10),
|
|
max_locations=subscription_data.get('max_locations', 3),
|
|
max_products=subscription_data.get('max_products', 500),
|
|
features=subscription_data.get('features', {}),
|
|
trial_ends_at=parse_date_field(
|
|
subscription_data.get('trial_ends_at'),
|
|
session_time,
|
|
"trial_ends_at"
|
|
),
|
|
next_billing_date=parse_date_field(
|
|
subscription_data.get('next_billing_date'),
|
|
session_time,
|
|
"next_billing_date"
|
|
),
|
|
stripe_subscription_id=subscription_data.get('stripe_subscription_id'),
|
|
stripe_customer_id=subscription_data.get('stripe_customer_id'),
|
|
cancelled_at=parse_date_field(
|
|
subscription_data.get('cancelled_at'),
|
|
session_time,
|
|
"cancelled_at"
|
|
),
|
|
cancellation_effective_date=parse_date_field(
|
|
subscription_data.get('cancellation_effective_date'),
|
|
session_time,
|
|
"cancellation_effective_date"
|
|
)
|
|
)
|
|
|
|
db.add(subscription)
|
|
await db.commit()
|
|
|
|
logger.info("Subscription loaded from seed data successfully",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
plan=subscription.plan)
|
|
else:
|
|
logger.warning("No subscription found in seed data",
|
|
virtual_tenant_id=virtual_tenant_id)
|
|
else:
|
|
logger.warning("Seed data file not found, falling back to default subscription",
|
|
file_path=str(json_file))
|
|
# Create default subscription if seed data not available
|
|
subscription = Subscription(
|
|
tenant_id=virtual_uuid,
|
|
plan="professional" if demo_account_type == "professional" else "enterprise",
|
|
status="active",
|
|
monthly_price=299.00 if demo_account_type == "professional" else 799.00,
|
|
max_users=10 if demo_account_type == "professional" else 50,
|
|
max_locations=3 if demo_account_type == "professional" else -1,
|
|
max_products=500 if demo_account_type == "professional" else -1,
|
|
features={
|
|
"production_planning": True,
|
|
"procurement_management": True,
|
|
"inventory_management": True,
|
|
"sales_analytics": True,
|
|
"multi_location": True,
|
|
"advanced_reporting": True,
|
|
"api_access": True,
|
|
"priority_support": True
|
|
},
|
|
next_billing_date=datetime.now(timezone.utc) + timedelta(days=90)
|
|
)
|
|
|
|
db.add(subscription)
|
|
await db.commit()
|
|
|
|
logger.info("Default subscription created",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
plan=subscription.plan)
|
|
|
|
# Return success - idempotent operation
|
|
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
|
return {
|
|
"service": "tenant",
|
|
"status": "completed",
|
|
"records_cloned": 0 if existing_subscription else 1,
|
|
"duration_ms": duration_ms,
|
|
"details": {
|
|
"tenant_already_exists": True,
|
|
"tenant_id": str(virtual_uuid),
|
|
"subscription_created": not existing_subscription
|
|
}
|
|
}
|
|
|
|
# Create virtual tenant record with required fields
|
|
# Note: Use the actual demo user IDs from seed_demo_users.py
|
|
# These match the demo users created in the auth service
|
|
DEMO_OWNER_IDS = {
|
|
"professional": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López
|
|
"enterprise": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz
|
|
}
|
|
demo_owner_uuid = uuid.UUID(DEMO_OWNER_IDS.get(demo_account_type, DEMO_OWNER_IDS["professional"]))
|
|
|
|
tenant = Tenant(
|
|
id=virtual_uuid,
|
|
name=f"Demo Tenant - {demo_account_type.replace('_', ' ').title()}",
|
|
address="Calle Demo 123", # Required field - provide demo address
|
|
city="Madrid",
|
|
postal_code="28001",
|
|
business_type="bakery",
|
|
is_demo=True,
|
|
is_demo_template=False,
|
|
demo_session_id=session_id, # Link tenant to demo session
|
|
business_model=demo_account_type,
|
|
is_active=True,
|
|
timezone="Europe/Madrid",
|
|
owner_id=demo_owner_uuid, # Required field - matches seed_demo_users.py
|
|
tenant_type="parent" if demo_account_type in ["enterprise", "enterprise_parent"] else "standalone"
|
|
)
|
|
|
|
db.add(tenant)
|
|
await db.flush() # Flush to get the tenant ID
|
|
|
|
# Create demo subscription with appropriate tier based on demo account type
|
|
|
|
# Determine subscription tier based on demo account type
|
|
if demo_account_type == "professional":
|
|
plan = "professional"
|
|
max_locations = 3
|
|
elif demo_account_type in ["enterprise", "enterprise_parent"]:
|
|
plan = "enterprise"
|
|
max_locations = -1 # Unlimited
|
|
elif demo_account_type == "enterprise_child":
|
|
plan = "enterprise"
|
|
max_locations = 1
|
|
else:
|
|
plan = "starter"
|
|
max_locations = 1
|
|
|
|
demo_subscription = Subscription(
|
|
tenant_id=tenant.id,
|
|
plan=plan, # Set appropriate tier based on demo account type
|
|
status="active",
|
|
monthly_price=0.0, # Free for demo
|
|
billing_cycle="monthly",
|
|
max_users=-1, # Unlimited for demo
|
|
max_locations=max_locations,
|
|
max_products=-1, # Unlimited for demo
|
|
features={}
|
|
)
|
|
db.add(demo_subscription)
|
|
|
|
# Create tenant member records for demo owner and staff
|
|
import json
|
|
|
|
# Helper function to get permissions for role
|
|
def get_permissions_for_role(role: str) -> str:
|
|
permission_map = {
|
|
"owner": ["read", "write", "admin", "delete"],
|
|
"admin": ["read", "write", "admin"],
|
|
"production_manager": ["read", "write"],
|
|
"baker": ["read", "write"],
|
|
"sales": ["read", "write"],
|
|
"quality_control": ["read", "write"],
|
|
"warehouse": ["read", "write"],
|
|
"logistics": ["read", "write"],
|
|
"procurement": ["read", "write"],
|
|
"maintenance": ["read", "write"],
|
|
"member": ["read", "write"],
|
|
"viewer": ["read"]
|
|
}
|
|
permissions = permission_map.get(role, ["read"])
|
|
return json.dumps(permissions)
|
|
|
|
# Define staff users for each demo account type (must match seed_demo_tenant_members.py)
|
|
STAFF_USERS = {
|
|
"professional": [
|
|
# Owner
|
|
{
|
|
"user_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"),
|
|
"role": "owner"
|
|
},
|
|
# Staff
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000001"),
|
|
"role": "baker"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000002"),
|
|
"role": "sales"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000003"),
|
|
"role": "quality_control"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000004"),
|
|
"role": "admin"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000005"),
|
|
"role": "warehouse"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000006"),
|
|
"role": "production_manager"
|
|
}
|
|
],
|
|
"enterprise": [
|
|
# Owner
|
|
{
|
|
"user_id": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"),
|
|
"role": "owner"
|
|
},
|
|
# Staff
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000011"),
|
|
"role": "production_manager"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000012"),
|
|
"role": "quality_control"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000013"),
|
|
"role": "logistics"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000014"),
|
|
"role": "sales"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000015"),
|
|
"role": "procurement"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000016"),
|
|
"role": "maintenance"
|
|
}
|
|
]
|
|
}
|
|
|
|
# Get staff users for this demo account type
|
|
staff_users = STAFF_USERS.get(demo_account_type, [])
|
|
|
|
# Create tenant member records for all users (owner + staff)
|
|
members_created = 0
|
|
for staff_member in staff_users:
|
|
tenant_member = TenantMember(
|
|
tenant_id=virtual_uuid,
|
|
user_id=staff_member["user_id"],
|
|
role=staff_member["role"],
|
|
permissions=get_permissions_for_role(staff_member["role"]),
|
|
is_active=True,
|
|
invited_by=demo_owner_uuid,
|
|
invited_at=datetime.now(timezone.utc),
|
|
joined_at=datetime.now(timezone.utc),
|
|
created_at=datetime.now(timezone.utc)
|
|
)
|
|
db.add(tenant_member)
|
|
members_created += 1
|
|
|
|
logger.info(
|
|
"Created tenant members for virtual tenant",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
members_created=members_created
|
|
)
|
|
|
|
# Clone TenantLocations
|
|
from app.models.tenant_location import TenantLocation
|
|
|
|
base_uuid = uuid.UUID(base_tenant_id)
|
|
location_result = await db.execute(
|
|
select(TenantLocation).where(TenantLocation.tenant_id == base_uuid)
|
|
)
|
|
base_locations = location_result.scalars().all()
|
|
|
|
records_cloned = 1 + members_created # Tenant + TenantMembers
|
|
for base_location in base_locations:
|
|
virtual_location = TenantLocation(
|
|
id=uuid.uuid4(),
|
|
tenant_id=virtual_tenant_id,
|
|
name=base_location.name,
|
|
location_type=base_location.location_type,
|
|
address=base_location.address,
|
|
city=base_location.city,
|
|
postal_code=base_location.postal_code,
|
|
latitude=base_location.latitude,
|
|
longitude=base_location.longitude,
|
|
capacity=base_location.capacity,
|
|
delivery_windows=base_location.delivery_windows,
|
|
operational_hours=base_location.operational_hours,
|
|
max_delivery_radius_km=base_location.max_delivery_radius_km,
|
|
delivery_schedule_config=base_location.delivery_schedule_config,
|
|
is_active=base_location.is_active,
|
|
contact_person=base_location.contact_person,
|
|
contact_phone=base_location.contact_phone,
|
|
contact_email=base_location.contact_email,
|
|
metadata_=base_location.metadata_ if isinstance(base_location.metadata_, dict) else (base_location.metadata_ or {})
|
|
)
|
|
db.add(virtual_location)
|
|
records_cloned += 1
|
|
|
|
logger.info("Cloned TenantLocations", count=len(base_locations))
|
|
|
|
# Subscription already created earlier based on demo_account_type (lines 179-206)
|
|
# No need to clone from template - this prevents duplicate subscription creation
|
|
|
|
await db.commit()
|
|
await db.refresh(tenant)
|
|
|
|
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
|
|
|
logger.info(
|
|
"Virtual tenant created successfully with subscription",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
tenant_name=tenant.name,
|
|
subscription_plan=plan,
|
|
duration_ms=duration_ms
|
|
)
|
|
|
|
records_cloned = 1 + members_created + 1 # Tenant + TenantMembers + Subscription
|
|
|
|
return {
|
|
"service": "tenant",
|
|
"status": "completed",
|
|
"records_cloned": records_cloned,
|
|
"duration_ms": duration_ms,
|
|
"details": {
|
|
"tenant_id": str(tenant.id),
|
|
"tenant_name": tenant.name,
|
|
"business_model": tenant.business_model,
|
|
"owner_id": str(demo_owner_uuid),
|
|
"members_created": members_created,
|
|
"subscription_plan": plan,
|
|
"subscription_created": True
|
|
}
|
|
}
|
|
|
|
except ValueError as e:
|
|
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
|
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to clone tenant data",
|
|
error=str(e),
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
exc_info=True
|
|
)
|
|
|
|
# Rollback on error
|
|
await db.rollback()
|
|
|
|
return {
|
|
"service": "tenant",
|
|
"status": "failed",
|
|
"records_cloned": 0,
|
|
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
|
"error": str(e)
|
|
}
|
|
|
|
|
|
@router.post("/create-child")
|
|
async def create_child_outlet(
|
|
request: dict,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: bool = Depends(verify_internal_api_key)
|
|
):
|
|
"""
|
|
Create a child outlet tenant for enterprise demos
|
|
|
|
Args:
|
|
request: JSON request body with child tenant details
|
|
|
|
Returns:
|
|
Creation status and tenant details
|
|
"""
|
|
# Extract parameters from request body
|
|
base_tenant_id = request.get("base_tenant_id")
|
|
virtual_tenant_id = request.get("virtual_tenant_id")
|
|
parent_tenant_id = request.get("parent_tenant_id")
|
|
child_name = request.get("child_name")
|
|
location = request.get("location", {})
|
|
session_id = request.get("session_id")
|
|
|
|
start_time = datetime.now(timezone.utc)
|
|
|
|
logger.info(
|
|
"Creating child outlet tenant",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
parent_tenant_id=parent_tenant_id,
|
|
child_name=child_name,
|
|
session_id=session_id
|
|
)
|
|
|
|
try:
|
|
# Validate UUIDs
|
|
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
|
parent_uuid = uuid.UUID(parent_tenant_id)
|
|
|
|
# Check if child tenant already exists
|
|
result = await db.execute(select(Tenant).where(Tenant.id == virtual_uuid))
|
|
existing_tenant = result.scalars().first()
|
|
|
|
if existing_tenant:
|
|
logger.info(
|
|
"Child tenant already exists",
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
tenant_name=existing_tenant.name
|
|
)
|
|
|
|
# Return existing tenant - idempotent operation
|
|
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
|
return {
|
|
"service": "tenant",
|
|
"status": "completed",
|
|
"records_created": 0,
|
|
"duration_ms": duration_ms,
|
|
"details": {
|
|
"tenant_id": str(virtual_uuid),
|
|
"tenant_name": existing_tenant.name,
|
|
"already_exists": True
|
|
}
|
|
}
|
|
|
|
# Get parent tenant to retrieve the correct owner_id
|
|
parent_result = await db.execute(select(Tenant).where(Tenant.id == parent_uuid))
|
|
parent_tenant = parent_result.scalars().first()
|
|
|
|
if not parent_tenant:
|
|
logger.error("Parent tenant not found", parent_tenant_id=parent_tenant_id)
|
|
return {
|
|
"service": "tenant",
|
|
"status": "failed",
|
|
"records_created": 0,
|
|
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
|
"error": f"Parent tenant {parent_tenant_id} not found"
|
|
}
|
|
|
|
# Use the parent's owner_id for the child tenant (enterprise demo owner)
|
|
parent_owner_id = parent_tenant.owner_id
|
|
|
|
# Create child tenant with parent relationship
|
|
child_tenant = Tenant(
|
|
id=virtual_uuid,
|
|
name=child_name,
|
|
address=location.get("address", f"Calle Outlet {location.get('city', 'Madrid')}"),
|
|
city=location.get("city", "Madrid"),
|
|
postal_code=location.get("postal_code", "28001"),
|
|
business_type="bakery",
|
|
is_demo=True,
|
|
is_demo_template=False,
|
|
demo_session_id=session_id, # Link child tenant to demo session
|
|
business_model="retail_outlet",
|
|
is_active=True,
|
|
timezone="Europe/Madrid",
|
|
# Set parent relationship
|
|
parent_tenant_id=parent_uuid,
|
|
tenant_type="child",
|
|
hierarchy_path=f"{str(parent_uuid)}.{str(virtual_uuid)}",
|
|
|
|
# Owner ID - MUST match the parent tenant owner (enterprise demo owner)
|
|
# This ensures the parent owner can see and access child tenants
|
|
owner_id=parent_owner_id
|
|
)
|
|
|
|
db.add(child_tenant)
|
|
await db.flush() # Flush to get the tenant ID
|
|
|
|
# Create TenantLocation for this retail outlet
|
|
child_location = TenantLocation(
|
|
id=uuid.uuid4(),
|
|
tenant_id=virtual_uuid,
|
|
name=f"{child_name} - Retail Outlet",
|
|
location_type="retail_outlet",
|
|
address=location.get("address", f"Calle Outlet {location.get('city', 'Madrid')}"),
|
|
city=location.get("city", "Madrid"),
|
|
postal_code=location.get("postal_code", "28001"),
|
|
latitude=location.get("latitude"),
|
|
longitude=location.get("longitude"),
|
|
delivery_windows={
|
|
"monday": "07:00-10:00",
|
|
"wednesday": "07:00-10:00",
|
|
"friday": "07:00-10:00"
|
|
},
|
|
operational_hours={
|
|
"monday": "07:00-21:00",
|
|
"tuesday": "07:00-21:00",
|
|
"wednesday": "07:00-21:00",
|
|
"thursday": "07:00-21:00",
|
|
"friday": "07:00-21:00",
|
|
"saturday": "08:00-21:00",
|
|
"sunday": "09:00-21:00"
|
|
},
|
|
delivery_schedule_config={
|
|
"delivery_days": ["monday", "wednesday", "friday"],
|
|
"time_window": "07:00-10:00"
|
|
},
|
|
is_active=True
|
|
)
|
|
db.add(child_location)
|
|
logger.info("Created TenantLocation for child", child_id=str(virtual_uuid), location_name=child_location.name)
|
|
|
|
# Create parent tenant lookup to get the correct plan for the child
|
|
parent_result = await db.execute(
|
|
select(Subscription).where(
|
|
Subscription.tenant_id == parent_uuid,
|
|
Subscription.status == "active"
|
|
)
|
|
)
|
|
parent_subscription = parent_result.scalars().first()
|
|
|
|
# Child inherits the same plan as parent
|
|
parent_plan = parent_subscription.plan if parent_subscription else "enterprise"
|
|
|
|
child_subscription = Subscription(
|
|
tenant_id=child_tenant.id,
|
|
plan=parent_plan, # Child inherits the same plan as parent
|
|
status="active",
|
|
monthly_price=0.0, # Free for demo
|
|
billing_cycle="monthly",
|
|
max_users=10, # Demo limits
|
|
max_locations=1, # Single location for outlet
|
|
max_products=200,
|
|
features={}
|
|
)
|
|
db.add(child_subscription)
|
|
|
|
# Create basic tenant members like parent
|
|
import json
|
|
|
|
# Use the parent's owner_id (already retrieved above)
|
|
# This ensures consistency between tenant.owner_id and TenantMember records
|
|
|
|
# Create tenant member for owner
|
|
child_owner_member = TenantMember(
|
|
tenant_id=virtual_uuid,
|
|
user_id=parent_owner_id,
|
|
role="owner",
|
|
permissions=json.dumps(["read", "write", "admin", "delete"]),
|
|
is_active=True,
|
|
invited_by=parent_owner_id,
|
|
invited_at=datetime.now(timezone.utc),
|
|
joined_at=datetime.now(timezone.utc),
|
|
created_at=datetime.now(timezone.utc)
|
|
)
|
|
db.add(child_owner_member)
|
|
|
|
# Create staff members for the outlet from parent enterprise users
|
|
# Use parent's enterprise staff (from enterprise/parent/02-auth.json)
|
|
staff_users = [
|
|
{
|
|
"user_id": uuid.UUID("f6c54d0f-5899-4952-ad94-7a492c07167a"), # Laura López - Logistics
|
|
"role": "logistics_coord"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("80765906-0074-4206-8f58-5867df1975fd"), # José Martínez - Quality
|
|
"role": "quality_control"
|
|
},
|
|
{
|
|
"user_id": uuid.UUID("701cb9d2-6049-4bb9-8d3a-1b3bd3aae45f"), # Francisco Moreno - Warehouse
|
|
"role": "warehouse_supervisor"
|
|
}
|
|
]
|
|
|
|
members_created = 1 # Start with owner
|
|
for staff_member in staff_users:
|
|
tenant_member = TenantMember(
|
|
tenant_id=virtual_uuid,
|
|
user_id=staff_member["user_id"],
|
|
role=staff_member["role"],
|
|
permissions=json.dumps(["read", "write"]) if staff_member["role"] != "admin" else json.dumps(["read", "write", "admin"]),
|
|
is_active=True,
|
|
invited_by=parent_owner_id,
|
|
invited_at=datetime.now(timezone.utc),
|
|
joined_at=datetime.now(timezone.utc),
|
|
created_at=datetime.now(timezone.utc)
|
|
)
|
|
db.add(tenant_member)
|
|
members_created += 1
|
|
|
|
await db.commit()
|
|
await db.refresh(child_tenant)
|
|
|
|
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
|
|
|
logger.info(
|
|
"Child outlet created successfully",
|
|
virtual_tenant_id=str(virtual_tenant_id),
|
|
parent_tenant_id=str(parent_tenant_id),
|
|
child_name=child_name,
|
|
owner_id=str(parent_owner_id),
|
|
duration_ms=duration_ms
|
|
)
|
|
|
|
return {
|
|
"service": "tenant",
|
|
"status": "completed",
|
|
"records_created": 2 + members_created, # Tenant + Subscription + Members
|
|
"duration_ms": duration_ms,
|
|
"details": {
|
|
"tenant_id": str(child_tenant.id),
|
|
"tenant_name": child_tenant.name,
|
|
"parent_tenant_id": str(parent_tenant_id),
|
|
"location": location,
|
|
"members_created": members_created,
|
|
"subscription_plan": "enterprise"
|
|
}
|
|
}
|
|
|
|
except ValueError as e:
|
|
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
|
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to create child outlet",
|
|
error=str(e),
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
parent_tenant_id=parent_tenant_id,
|
|
exc_info=True
|
|
)
|
|
|
|
# Rollback on error
|
|
await db.rollback()
|
|
|
|
return {
|
|
"service": "tenant",
|
|
"status": "failed",
|
|
"records_created": 0,
|
|
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
|
"error": str(e)
|
|
}
|
|
|
|
|
|
@router.get("/clone/health")
|
|
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
|
|
"""
|
|
Health check for internal cloning endpoint
|
|
Used by orchestrator to verify service availability
|
|
"""
|
|
return {
|
|
"service": "tenant",
|
|
"clone_endpoint": "available",
|
|
"version": "2.0.0"
|
|
}
|