2025-12-13 23:57:54 +01:00
|
|
|
"""
|
|
|
|
|
Internal Demo Cloning API for Auth Service
|
|
|
|
|
Service-to-service endpoint for cloning authentication and user 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
|
|
|
|
|
from typing import Optional
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
2025-12-14 11:58:14 +01:00
|
|
|
import json
|
2025-12-13 23:57:54 +01:00
|
|
|
|
|
|
|
|
# Add shared path
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
|
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
|
|
|
|
from app.models.users import User
|
|
|
|
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
2026-01-02 11:12:50 +01:00
|
|
|
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
2025-12-13 23:57:54 +01:00
|
|
|
|
|
|
|
|
# Base demo tenant IDs
|
|
|
|
|
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
|
|
|
|
|
|
|
|
|
|
2026-01-02 11:12:50 +01:00
|
|
|
@router.post("/clone")
|
2025-12-13 23:57:54 +01:00
|
|
|
async def clone_demo_data(
|
|
|
|
|
base_tenant_id: str,
|
|
|
|
|
virtual_tenant_id: str,
|
|
|
|
|
demo_account_type: str,
|
|
|
|
|
session_id: Optional[str] = None,
|
|
|
|
|
session_created_at: Optional[str] = None,
|
2026-01-12 14:24:14 +01:00
|
|
|
db: AsyncSession = Depends(get_db)
|
2025-12-13 23:57:54 +01:00
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Clone auth service data for a virtual demo tenant
|
|
|
|
|
|
|
|
|
|
Clones:
|
|
|
|
|
- Demo users (owner and staff)
|
|
|
|
|
|
|
|
|
|
Note: Tenant memberships are handled by the tenant service's internal_demo endpoint
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
base_tenant_id: Template tenant UUID to clone from
|
|
|
|
|
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 counts
|
|
|
|
|
"""
|
|
|
|
|
start_time = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
# Parse session creation time
|
|
|
|
|
if session_created_at:
|
|
|
|
|
try:
|
|
|
|
|
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
|
|
|
|
except (ValueError, AttributeError):
|
|
|
|
|
session_time = start_time
|
|
|
|
|
else:
|
|
|
|
|
session_time = start_time
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Starting auth data cloning",
|
|
|
|
|
base_tenant_id=base_tenant_id,
|
|
|
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
|
|
|
demo_account_type=demo_account_type,
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
session_created_at=session_created_at
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Validate UUIDs
|
|
|
|
|
base_uuid = uuid.UUID(base_tenant_id)
|
|
|
|
|
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
|
|
|
|
|
|
|
|
|
# Note: We don't check for existing users since User model doesn't have demo_session_id
|
|
|
|
|
# Demo users are identified by their email addresses from the seed data
|
|
|
|
|
# Idempotency is handled by checking if each user email already exists below
|
|
|
|
|
|
|
|
|
|
# Load demo users from JSON seed file
|
2025-12-17 13:03:52 +01:00
|
|
|
from shared.utils.seed_data_paths import get_seed_data_path
|
|
|
|
|
|
|
|
|
|
if demo_account_type == "professional":
|
|
|
|
|
json_file = get_seed_data_path("professional", "02-auth.json")
|
|
|
|
|
elif demo_account_type == "enterprise":
|
|
|
|
|
json_file = get_seed_data_path("enterprise", "02-auth.json")
|
|
|
|
|
elif demo_account_type == "enterprise_child":
|
|
|
|
|
# Child locations don't have separate auth data - they share parent's users
|
|
|
|
|
logger.info("enterprise_child uses parent tenant auth, skipping user cloning", virtual_tenant_id=virtual_tenant_id)
|
|
|
|
|
return {
|
|
|
|
|
"service": "auth",
|
|
|
|
|
"status": "completed",
|
|
|
|
|
"records_cloned": 0,
|
|
|
|
|
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
|
|
|
|
"details": {"users": 0, "note": "Child locations share parent auth"}
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
2025-12-13 23:57:54 +01:00
|
|
|
|
|
|
|
|
# Load JSON data
|
|
|
|
|
import json
|
|
|
|
|
with open(json_file, 'r', encoding='utf-8') as f:
|
|
|
|
|
seed_data = json.load(f)
|
|
|
|
|
|
|
|
|
|
# Get demo users for this account type
|
|
|
|
|
demo_users_data = seed_data.get("users", [])
|
|
|
|
|
|
|
|
|
|
records_cloned = 0
|
|
|
|
|
|
|
|
|
|
# Create users and tenant memberships
|
|
|
|
|
for user_data in demo_users_data:
|
|
|
|
|
user_id = uuid.UUID(user_data["id"])
|
|
|
|
|
|
|
|
|
|
# Create user if not exists
|
|
|
|
|
user_result = await db.execute(
|
|
|
|
|
select(User).where(User.id == user_id)
|
|
|
|
|
)
|
|
|
|
|
existing_user = user_result.scalars().first()
|
|
|
|
|
|
|
|
|
|
if not existing_user:
|
|
|
|
|
# Apply date adjustments to created_at and updated_at
|
|
|
|
|
from shared.utils.demo_dates import adjust_date_for_demo
|
|
|
|
|
|
|
|
|
|
# Adjust created_at date
|
|
|
|
|
created_at_str = user_data.get("created_at", session_time.isoformat())
|
|
|
|
|
if isinstance(created_at_str, str):
|
|
|
|
|
try:
|
|
|
|
|
original_created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
|
|
|
|
|
adjusted_created_at = adjust_date_for_demo(original_created_at, session_time)
|
|
|
|
|
except ValueError:
|
|
|
|
|
adjusted_created_at = session_time
|
|
|
|
|
else:
|
|
|
|
|
adjusted_created_at = session_time
|
|
|
|
|
|
|
|
|
|
# Adjust updated_at date (same as created_at for demo users)
|
|
|
|
|
adjusted_updated_at = adjusted_created_at
|
|
|
|
|
|
|
|
|
|
# Get full_name from either "name" or "full_name" field
|
|
|
|
|
full_name = user_data.get("full_name") or user_data.get("name", "Demo User")
|
|
|
|
|
|
|
|
|
|
# For demo users, use a placeholder hashed password (they won't actually log in)
|
|
|
|
|
# In production, this would be properly hashed
|
|
|
|
|
demo_hashed_password = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqNlI.eFKW" # "demo_password"
|
|
|
|
|
|
|
|
|
|
user = User(
|
|
|
|
|
id=user_id,
|
|
|
|
|
email=user_data["email"],
|
|
|
|
|
full_name=full_name,
|
|
|
|
|
hashed_password=demo_hashed_password,
|
|
|
|
|
is_active=user_data.get("is_active", True),
|
|
|
|
|
is_verified=True,
|
|
|
|
|
role=user_data.get("role", "member"),
|
|
|
|
|
language=user_data.get("language", "es"),
|
|
|
|
|
timezone=user_data.get("timezone", "Europe/Madrid"),
|
|
|
|
|
created_at=adjusted_created_at,
|
|
|
|
|
updated_at=adjusted_updated_at
|
|
|
|
|
)
|
|
|
|
|
db.add(user)
|
|
|
|
|
records_cloned += 1
|
|
|
|
|
|
|
|
|
|
# Note: Tenant memberships are handled by tenant service
|
|
|
|
|
# Only create users in auth service
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Auth data cloning completed",
|
|
|
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
records_cloned=records_cloned,
|
|
|
|
|
duration_ms=duration_ms
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"service": "auth",
|
|
|
|
|
"status": "completed",
|
|
|
|
|
"records_cloned": records_cloned,
|
|
|
|
|
"base_tenant_id": str(base_tenant_id),
|
|
|
|
|
"virtual_tenant_id": str(virtual_tenant_id),
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
"demo_account_type": demo_account_type,
|
|
|
|
|
"duration_ms": duration_ms
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 auth data",
|
|
|
|
|
error=str(e),
|
|
|
|
|
virtual_tenant_id=virtual_tenant_id,
|
|
|
|
|
exc_info=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Rollback on error
|
|
|
|
|
await db.rollback()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"service": "auth",
|
|
|
|
|
"status": "failed",
|
|
|
|
|
"records_cloned": 0,
|
|
|
|
|
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
|
|
|
|
"error": str(e)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/clone/health")
|
2026-01-12 14:24:14 +01:00
|
|
|
async def clone_health_check():
|
2025-12-13 23:57:54 +01:00
|
|
|
"""
|
|
|
|
|
Health check for internal cloning endpoint
|
|
|
|
|
Used by orchestrator to verify service availability
|
|
|
|
|
"""
|
|
|
|
|
return {
|
|
|
|
|
"service": "auth",
|
|
|
|
|
"clone_endpoint": "available",
|
|
|
|
|
"version": "1.0.0"
|
|
|
|
|
}
|