Initial commit - production deployment
This commit is contained in:
8
services/tenant/app/api/__init__.py
Normal file
8
services/tenant/app/api/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Tenant API Package
|
||||
API endpoints for tenant management
|
||||
"""
|
||||
|
||||
from . import tenants
|
||||
|
||||
__all__ = ["tenants"]
|
||||
359
services/tenant/app/api/enterprise_upgrade.py
Normal file
359
services/tenant/app/api/enterprise_upgrade.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Enterprise Upgrade API
|
||||
Endpoints for upgrading tenants to enterprise tier and managing child outlets
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any, Optional
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tenants import Tenant
|
||||
from app.models.tenant_location import TenantLocation
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from app.core.config import settings
|
||||
from shared.auth.tenant_access import verify_tenant_permission_dep
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.clients.subscription_client import SubscriptionServiceClient, get_subscription_service_client
|
||||
from shared.subscription.plans import SubscriptionTier, QuotaLimits
|
||||
from shared.database.base import create_database_manager
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return EnhancedTenantService(database_manager)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
# Pydantic models for request bodies
|
||||
class EnterpriseUpgradeRequest(BaseModel):
|
||||
location_name: Optional[str] = Field(default="Central Production Facility")
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
production_capacity_kg: Optional[int] = Field(default=1000)
|
||||
|
||||
|
||||
class ChildOutletRequest(BaseModel):
|
||||
name: str
|
||||
subdomain: str
|
||||
address: str
|
||||
city: Optional[str] = None
|
||||
postal_code: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
delivery_days: Optional[list] = None
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/upgrade-to-enterprise")
|
||||
async def upgrade_to_enterprise(
|
||||
tenant_id: str,
|
||||
upgrade_data: EnterpriseUpgradeRequest,
|
||||
subscription_client: SubscriptionServiceClient = Depends(get_subscription_service_client),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Upgrade a tenant to enterprise tier with central production facility
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
# Get the current tenant
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
tenant = await tenant_repo.get_by_id(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
# Verify current subscription allows upgrade to enterprise
|
||||
current_subscription = await subscription_client.get_subscription(tenant_id)
|
||||
if current_subscription['plan'] not in [SubscriptionTier.STARTER.value, SubscriptionTier.PROFESSIONAL.value]:
|
||||
raise HTTPException(status_code=400, detail="Only starter and professional tier tenants can be upgraded to enterprise")
|
||||
|
||||
# Verify user has admin/owner role
|
||||
# This is handled by current_user check
|
||||
|
||||
# Update tenant to parent type
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
updated_tenant = await tenant_repo.update(
|
||||
tenant_id,
|
||||
{
|
||||
'tenant_type': 'parent',
|
||||
'hierarchy_path': f"{tenant_id}" # Root path
|
||||
}
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Create central production location
|
||||
location_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'name': upgrade_data.location_name,
|
||||
'location_type': 'central_production',
|
||||
'address': upgrade_data.address or tenant.address,
|
||||
'city': upgrade_data.city or tenant.city,
|
||||
'postal_code': upgrade_data.postal_code or tenant.postal_code,
|
||||
'latitude': upgrade_data.latitude or tenant.latitude,
|
||||
'longitude': upgrade_data.longitude or tenant.longitude,
|
||||
'capacity': upgrade_data.production_capacity_kg,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
from app.repositories.tenant_location_repository import TenantLocationRepository
|
||||
from app.core.database import database_manager
|
||||
|
||||
# Create async session
|
||||
async with database_manager.get_session() as session:
|
||||
location_repo = TenantLocationRepository(session)
|
||||
created_location = await location_repo.create_location(location_data)
|
||||
await session.commit()
|
||||
|
||||
# Update subscription to enterprise tier
|
||||
await subscription_client.update_subscription_plan(
|
||||
tenant_id=tenant_id,
|
||||
new_plan=SubscriptionTier.ENTERPRISE.value
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'tenant': updated_tenant,
|
||||
'production_location': created_location,
|
||||
'message': 'Tenant successfully upgraded to enterprise tier'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upgrade tenant: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{parent_id}/add-child-outlet")
|
||||
async def add_child_outlet(
|
||||
parent_id: str,
|
||||
child_data: ChildOutletRequest,
|
||||
subscription_client: SubscriptionServiceClient = Depends(get_subscription_service_client),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Add a new child outlet to a parent tenant
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
# Get parent tenant and verify it's a parent
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
parent_tenant = await tenant_repo.get_by_id(parent_id)
|
||||
if not parent_tenant:
|
||||
raise HTTPException(status_code=400, detail="Parent tenant not found")
|
||||
|
||||
parent_dict = {
|
||||
'id': str(parent_tenant.id),
|
||||
'name': parent_tenant.name,
|
||||
'tenant_type': parent_tenant.tenant_type,
|
||||
'subscription_tier': parent_tenant.subscription_tier,
|
||||
'business_type': parent_tenant.business_type,
|
||||
'business_model': parent_tenant.business_model,
|
||||
'city': parent_tenant.city,
|
||||
'phone': parent_tenant.phone,
|
||||
'email': parent_tenant.email,
|
||||
'owner_id': parent_tenant.owner_id
|
||||
}
|
||||
|
||||
if parent_dict.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=400, detail="Tenant is not a parent type")
|
||||
|
||||
# Validate subscription tier
|
||||
from shared.clients import get_tenant_client
|
||||
from shared.subscription.plans import PlanFeatures
|
||||
|
||||
tenant_client = get_tenant_client(config=settings, service_name="tenant-service")
|
||||
subscription = await tenant_client.get_tenant_subscription(parent_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="No active subscription found for parent tenant"
|
||||
)
|
||||
|
||||
tier = subscription.get("plan", "starter")
|
||||
if not PlanFeatures.validate_tenant_access(tier, "child"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Creating child outlets requires Enterprise subscription. Current plan: {tier}"
|
||||
)
|
||||
|
||||
# Check if parent has reached child quota
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
current_child_count = await tenant_repo.get_child_tenant_count(parent_id)
|
||||
|
||||
# Get max children from subscription plan
|
||||
max_children = QuotaLimits.get_limit("MAX_CHILD_TENANTS", tier)
|
||||
|
||||
if max_children is not None and current_child_count >= max_children:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Child tenant limit reached. Current: {current_child_count}, Maximum: {max_children}"
|
||||
)
|
||||
|
||||
# Create new child tenant
|
||||
child_id = str(uuid.uuid4())
|
||||
child_tenant_data = {
|
||||
'id': child_id,
|
||||
'name': child_data.name,
|
||||
'subdomain': child_data.subdomain,
|
||||
'business_type': parent_dict.get('business_type', 'bakery'),
|
||||
'business_model': parent_dict.get('business_model', 'retail_bakery'),
|
||||
'address': child_data.address,
|
||||
'city': child_data.city or parent_dict.get('city'),
|
||||
'postal_code': child_data.postal_code,
|
||||
'latitude': child_data.latitude,
|
||||
'longitude': child_data.longitude,
|
||||
'phone': child_data.phone or parent_dict.get('phone'),
|
||||
'email': child_data.email or parent_dict.get('email'),
|
||||
'parent_tenant_id': parent_id,
|
||||
'tenant_type': 'child',
|
||||
'hierarchy_path': f"{parent_id}.{child_id}",
|
||||
'owner_id': parent_dict.get('owner_id'), # Same owner as parent
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
# Use database managed session
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
created_child = await tenant_repo.create(child_tenant_data)
|
||||
await session.commit()
|
||||
|
||||
created_child_dict = {
|
||||
'id': str(created_child.id),
|
||||
'name': created_child.name,
|
||||
'subdomain': created_child.subdomain
|
||||
}
|
||||
|
||||
# Create retail outlet location for the child
|
||||
location_data = {
|
||||
'tenant_id': uuid.UUID(child_id),
|
||||
'name': f"Outlet - {child_data.name}",
|
||||
'location_type': 'retail_outlet',
|
||||
'address': child_data.address,
|
||||
'city': child_data.city or parent_dict.get('city'),
|
||||
'postal_code': child_data.postal_code,
|
||||
'latitude': child_data.latitude,
|
||||
'longitude': child_data.longitude,
|
||||
'delivery_windows': child_data.delivery_days,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
from app.repositories.tenant_location_repository import TenantLocationRepository
|
||||
|
||||
# Create async session
|
||||
async with database_manager.get_session() as session:
|
||||
location_repo = TenantLocationRepository(session)
|
||||
created_location = await location_repo.create_location(location_data)
|
||||
await session.commit()
|
||||
|
||||
location_dict = {
|
||||
'id': str(created_location.id) if created_location else None,
|
||||
'name': created_location.name if created_location else None
|
||||
}
|
||||
|
||||
# Copy relevant settings from parent (with child-specific overrides)
|
||||
# This would typically involve copying settings via tenant settings service
|
||||
|
||||
# Create child subscription inheriting from parent
|
||||
await subscription_client.create_child_subscription(
|
||||
child_tenant_id=child_id,
|
||||
parent_tenant_id=parent_id
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'child_tenant': created_child_dict,
|
||||
'location': location_dict,
|
||||
'message': 'Child outlet successfully added'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add child outlet: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/hierarchy")
|
||||
async def get_tenant_hierarchy(
|
||||
tenant_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Get tenant hierarchy information
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
tenant = await tenant_repo.get_by_id(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
result = {
|
||||
'tenant_id': tenant_id,
|
||||
'name': tenant.name,
|
||||
'tenant_type': tenant.tenant_type,
|
||||
'parent_tenant_id': tenant.parent_tenant_id,
|
||||
'hierarchy_path': tenant.hierarchy_path,
|
||||
'is_parent': tenant.tenant_type == 'parent',
|
||||
'is_child': tenant.tenant_type == 'child'
|
||||
}
|
||||
|
||||
# If this is a parent, include child count
|
||||
if tenant.tenant_type == 'parent':
|
||||
child_count = await tenant_repo.get_child_tenant_count(tenant_id)
|
||||
result['child_tenant_count'] = child_count
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get hierarchy: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/tenant-hierarchy")
|
||||
async def get_user_accessible_tenant_hierarchy(
|
||||
user_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Get all tenants a user has access to, organized in hierarchy
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
# Fetch all tenants where user has access, organized hierarchically
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
user_tenants = await tenant_repo.get_user_tenants_with_hierarchy(user_id)
|
||||
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'tenants': user_tenants,
|
||||
'total_count': len(user_tenants)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get user hierarchy: {str(e)}")
|
||||
827
services/tenant/app/api/internal_demo.py
Normal file
827
services/tenant/app/api/internal_demo.py
Normal file
@@ -0,0 +1,827 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
|
||||
@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)
|
||||
):
|
||||
"""
|
||||
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"
|
||||
),
|
||||
subscription_id=subscription_data.get('stripe_subscription_id'),
|
||||
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"
|
||||
),
|
||||
is_tenant_linked=True # Required for check constraint when tenant_id is set
|
||||
)
|
||||
|
||||
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),
|
||||
is_tenant_linked=True # Required for check constraint when tenant_id is set
|
||||
)
|
||||
|
||||
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={},
|
||||
is_tenant_linked=True # Required for check constraint when tenant_id is set
|
||||
)
|
||||
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)
|
||||
):
|
||||
"""
|
||||
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={},
|
||||
is_tenant_linked=True # Required for check constraint when tenant_id is set
|
||||
)
|
||||
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():
|
||||
"""
|
||||
Health check for internal cloning endpoint
|
||||
Used by orchestrator to verify service availability
|
||||
"""
|
||||
return {
|
||||
"service": "tenant",
|
||||
"clone_endpoint": "available",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
445
services/tenant/app/api/network_alerts.py
Normal file
445
services/tenant/app/api/network_alerts.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Network Alerts API
|
||||
Endpoints for aggregating and managing alerts across enterprise networks
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
import structlog
|
||||
|
||||
from app.services.network_alerts_service import NetworkAlertsService
|
||||
from shared.auth.tenant_access import verify_tenant_permission_dep
|
||||
from shared.clients import get_tenant_client, get_alerts_client
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic models for request/response
|
||||
class NetworkAlert(BaseModel):
|
||||
alert_id: str = Field(..., description="Unique alert ID")
|
||||
tenant_id: str = Field(..., description="Tenant ID where alert originated")
|
||||
tenant_name: str = Field(..., description="Tenant name")
|
||||
alert_type: str = Field(..., description="Type of alert: inventory, production, delivery, etc.")
|
||||
severity: str = Field(..., description="Severity: critical, high, medium, low")
|
||||
title: str = Field(..., description="Alert title")
|
||||
message: str = Field(..., description="Alert message")
|
||||
timestamp: str = Field(..., description="Alert timestamp")
|
||||
status: str = Field(..., description="Alert status: active, acknowledged, resolved")
|
||||
source_system: str = Field(..., description="System that generated the alert")
|
||||
related_entity_id: Optional[str] = Field(None, description="ID of related entity (product, route, etc.)")
|
||||
related_entity_type: Optional[str] = Field(None, description="Type of related entity")
|
||||
|
||||
|
||||
class AlertSeveritySummary(BaseModel):
|
||||
critical_count: int = Field(..., description="Number of critical alerts")
|
||||
high_count: int = Field(..., description="Number of high severity alerts")
|
||||
medium_count: int = Field(..., description="Number of medium severity alerts")
|
||||
low_count: int = Field(..., description="Number of low severity alerts")
|
||||
total_alerts: int = Field(..., description="Total number of alerts")
|
||||
|
||||
|
||||
class AlertTypeSummary(BaseModel):
|
||||
inventory_alerts: int = Field(..., description="Inventory-related alerts")
|
||||
production_alerts: int = Field(..., description="Production-related alerts")
|
||||
delivery_alerts: int = Field(..., description="Delivery-related alerts")
|
||||
equipment_alerts: int = Field(..., description="Equipment-related alerts")
|
||||
quality_alerts: int = Field(..., description="Quality-related alerts")
|
||||
other_alerts: int = Field(..., description="Other types of alerts")
|
||||
|
||||
|
||||
class NetworkAlertsSummary(BaseModel):
|
||||
total_alerts: int = Field(..., description="Total alerts across network")
|
||||
active_alerts: int = Field(..., description="Currently active alerts")
|
||||
acknowledged_alerts: int = Field(..., description="Acknowledged alerts")
|
||||
resolved_alerts: int = Field(..., description="Resolved alerts")
|
||||
severity_summary: AlertSeveritySummary = Field(..., description="Alerts by severity")
|
||||
type_summary: AlertTypeSummary = Field(..., description="Alerts by type")
|
||||
most_recent_alert: Optional[NetworkAlert] = Field(None, description="Most recent alert")
|
||||
|
||||
|
||||
class AlertCorrelation(BaseModel):
|
||||
correlation_id: str = Field(..., description="Correlation group ID")
|
||||
primary_alert: NetworkAlert = Field(..., description="Primary alert in the group")
|
||||
related_alerts: List[NetworkAlert] = Field(..., description="Alerts correlated with primary alert")
|
||||
correlation_type: str = Field(..., description="Type of correlation: causal, temporal, spatial")
|
||||
correlation_strength: float = Field(..., description="Correlation strength (0-1)")
|
||||
impact_analysis: str = Field(..., description="Analysis of combined impact")
|
||||
|
||||
|
||||
async def get_network_alerts_service() -> NetworkAlertsService:
|
||||
"""Dependency injection for NetworkAlertsService"""
|
||||
tenant_client = get_tenant_client(settings, "tenant-service")
|
||||
alerts_client = get_alerts_client(settings, "tenant-service")
|
||||
return NetworkAlertsService(tenant_client, alerts_client)
|
||||
|
||||
|
||||
@router.get("/tenants/{parent_id}/network/alerts",
|
||||
response_model=List[NetworkAlert],
|
||||
summary="Get aggregated alerts across network")
|
||||
async def get_network_alerts(
|
||||
parent_id: str,
|
||||
severity: Optional[str] = Query(None, description="Filter by severity: critical, high, medium, low"),
|
||||
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
|
||||
status: Optional[str] = Query(None, description="Filter by status: active, acknowledged, resolved"),
|
||||
limit: int = Query(100, description="Maximum number of alerts to return"),
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get aggregated alerts across all child tenants in a parent network
|
||||
|
||||
This endpoint provides a unified view of alerts across the entire enterprise network,
|
||||
enabling network managers to identify and prioritize issues that require attention.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can access network alerts"
|
||||
)
|
||||
|
||||
# Get all child tenants
|
||||
child_tenants = await network_alerts_service.get_child_tenants(parent_id)
|
||||
|
||||
if not child_tenants:
|
||||
return []
|
||||
|
||||
# Aggregate alerts from all child tenants
|
||||
all_alerts = []
|
||||
|
||||
for child in child_tenants:
|
||||
child_id = child['id']
|
||||
child_name = child['name']
|
||||
|
||||
# Get alerts for this child tenant
|
||||
child_alerts = await network_alerts_service.get_alerts_for_tenant(child_id)
|
||||
|
||||
# Enrich with tenant information and apply filters
|
||||
for alert in child_alerts:
|
||||
enriched_alert = {
|
||||
'alert_id': alert.get('alert_id', str(uuid.uuid4())),
|
||||
'tenant_id': child_id,
|
||||
'tenant_name': child_name,
|
||||
'alert_type': alert.get('alert_type', 'unknown'),
|
||||
'severity': alert.get('severity', 'medium'),
|
||||
'title': alert.get('title', 'No title'),
|
||||
'message': alert.get('message', 'No message'),
|
||||
'timestamp': alert.get('timestamp', datetime.now().isoformat()),
|
||||
'status': alert.get('status', 'active'),
|
||||
'source_system': alert.get('source_system', 'unknown'),
|
||||
'related_entity_id': alert.get('related_entity_id'),
|
||||
'related_entity_type': alert.get('related_entity_type')
|
||||
}
|
||||
|
||||
# Apply filters
|
||||
if severity and enriched_alert['severity'] != severity:
|
||||
continue
|
||||
if alert_type and enriched_alert['alert_type'] != alert_type:
|
||||
continue
|
||||
if status and enriched_alert['status'] != status:
|
||||
continue
|
||||
|
||||
all_alerts.append(enriched_alert)
|
||||
|
||||
# Sort by severity (critical first) and timestamp (newest first)
|
||||
severity_order = {'critical': 1, 'high': 2, 'medium': 3, 'low': 4}
|
||||
all_alerts.sort(key=lambda x: (severity_order.get(x['severity'], 5), -int(x['timestamp'] or 0)))
|
||||
|
||||
return all_alerts[:limit]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get network alerts", parent_id=parent_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get network alerts: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{parent_id}/network/alerts/summary",
|
||||
response_model=NetworkAlertsSummary,
|
||||
summary="Get network alerts summary")
|
||||
async def get_network_alerts_summary(
|
||||
parent_id: str,
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get summary of alerts across the network
|
||||
|
||||
Provides aggregated metrics and statistics about alerts across all child tenants.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can access network alerts summary"
|
||||
)
|
||||
|
||||
# Get all network alerts
|
||||
all_alerts = await network_alerts_service.get_network_alerts(parent_id)
|
||||
|
||||
if not all_alerts:
|
||||
return NetworkAlertsSummary(
|
||||
total_alerts=0,
|
||||
active_alerts=0,
|
||||
acknowledged_alerts=0,
|
||||
resolved_alerts=0,
|
||||
severity_summary=AlertSeveritySummary(
|
||||
critical_count=0,
|
||||
high_count=0,
|
||||
medium_count=0,
|
||||
low_count=0,
|
||||
total_alerts=0
|
||||
),
|
||||
type_summary=AlertTypeSummary(
|
||||
inventory_alerts=0,
|
||||
production_alerts=0,
|
||||
delivery_alerts=0,
|
||||
equipment_alerts=0,
|
||||
quality_alerts=0,
|
||||
other_alerts=0
|
||||
),
|
||||
most_recent_alert=None
|
||||
)
|
||||
|
||||
# Calculate summary metrics
|
||||
active_alerts = sum(1 for a in all_alerts if a['status'] == 'active')
|
||||
acknowledged_alerts = sum(1 for a in all_alerts if a['status'] == 'acknowledged')
|
||||
resolved_alerts = sum(1 for a in all_alerts if a['status'] == 'resolved')
|
||||
|
||||
# Calculate severity summary
|
||||
severity_summary = AlertSeveritySummary(
|
||||
critical_count=sum(1 for a in all_alerts if a['severity'] == 'critical'),
|
||||
high_count=sum(1 for a in all_alerts if a['severity'] == 'high'),
|
||||
medium_count=sum(1 for a in all_alerts if a['severity'] == 'medium'),
|
||||
low_count=sum(1 for a in all_alerts if a['severity'] == 'low'),
|
||||
total_alerts=len(all_alerts)
|
||||
)
|
||||
|
||||
# Calculate type summary
|
||||
type_summary = AlertTypeSummary(
|
||||
inventory_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'inventory'),
|
||||
production_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'production'),
|
||||
delivery_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'delivery'),
|
||||
equipment_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'equipment'),
|
||||
quality_alerts=sum(1 for a in all_alerts if a['alert_type'] == 'quality'),
|
||||
other_alerts=sum(1 for a in all_alerts if a['alert_type'] not in ['inventory', 'production', 'delivery', 'equipment', 'quality'])
|
||||
)
|
||||
|
||||
# Get most recent alert
|
||||
most_recent_alert = None
|
||||
if all_alerts:
|
||||
most_recent_alert = max(all_alerts, key=lambda x: x['timestamp'])
|
||||
|
||||
return NetworkAlertsSummary(
|
||||
total_alerts=len(all_alerts),
|
||||
active_alerts=active_alerts,
|
||||
acknowledged_alerts=acknowledged_alerts,
|
||||
resolved_alerts=resolved_alerts,
|
||||
severity_summary=severity_summary,
|
||||
type_summary=type_summary,
|
||||
most_recent_alert=most_recent_alert
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get network alerts summary", parent_id=parent_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get alerts summary: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{parent_id}/network/alerts/correlations",
|
||||
response_model=List[AlertCorrelation],
|
||||
summary="Get correlated alert groups")
|
||||
async def get_correlated_alerts(
|
||||
parent_id: str,
|
||||
min_correlation_strength: float = Query(0.7, ge=0.5, le=1.0, description="Minimum correlation strength"),
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get groups of correlated alerts
|
||||
|
||||
Identifies alerts that are related or have cascading effects across the network.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can access alert correlations"
|
||||
)
|
||||
|
||||
# Get all network alerts
|
||||
all_alerts = await network_alerts_service.get_network_alerts(parent_id)
|
||||
|
||||
if not all_alerts:
|
||||
return []
|
||||
|
||||
# Detect correlations (simplified for demo)
|
||||
correlations = await network_alerts_service.detect_alert_correlations(
|
||||
all_alerts, min_correlation_strength
|
||||
)
|
||||
|
||||
return correlations
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get correlated alerts", parent_id=parent_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get alert correlations: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{parent_id}/network/alerts/{alert_id}/acknowledge",
|
||||
summary="Acknowledge network alert")
|
||||
async def acknowledge_network_alert(
|
||||
parent_id: str,
|
||||
alert_id: str,
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Acknowledge a network alert
|
||||
|
||||
Marks an alert as acknowledged to indicate it's being addressed.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can acknowledge network alerts"
|
||||
)
|
||||
|
||||
# Acknowledge the alert
|
||||
result = await network_alerts_service.acknowledge_alert(parent_id, alert_id)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'alert_id': alert_id,
|
||||
'status': 'acknowledged',
|
||||
'message': 'Alert acknowledged successfully'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to acknowledge alert", parent_id=parent_id, alert_id=alert_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to acknowledge alert: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{parent_id}/network/alerts/{alert_id}/resolve",
|
||||
summary="Resolve network alert")
|
||||
async def resolve_network_alert(
|
||||
parent_id: str,
|
||||
alert_id: str,
|
||||
resolution_notes: Optional[str] = Query(None, description="Notes about resolution"),
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Resolve a network alert
|
||||
|
||||
Marks an alert as resolved after the issue has been addressed.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can resolve network alerts"
|
||||
)
|
||||
|
||||
# Resolve the alert
|
||||
result = await network_alerts_service.resolve_alert(parent_id, alert_id, resolution_notes)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'alert_id': alert_id,
|
||||
'status': 'resolved',
|
||||
'resolution_notes': resolution_notes,
|
||||
'message': 'Alert resolved successfully'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to resolve alert", parent_id=parent_id, alert_id=alert_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to resolve alert: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{parent_id}/network/alerts/trends",
|
||||
summary="Get alert trends over time")
|
||||
async def get_alert_trends(
|
||||
parent_id: str,
|
||||
days: int = Query(30, ge=7, le=365, description="Number of days to analyze"),
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get alert trends over time
|
||||
|
||||
Analyzes how alert patterns change over time to identify systemic issues.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can access alert trends"
|
||||
)
|
||||
|
||||
# Get alert trends
|
||||
trends = await network_alerts_service.get_alert_trends(parent_id, days)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'trends': trends,
|
||||
'period': f'Last {days} days'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get alert trends", parent_id=parent_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get alert trends: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{parent_id}/network/alerts/prioritization",
|
||||
summary="Get prioritized alerts")
|
||||
async def get_prioritized_alerts(
|
||||
parent_id: str,
|
||||
limit: int = Query(10, description="Maximum number of alerts to return"),
|
||||
network_alerts_service: NetworkAlertsService = Depends(get_network_alerts_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get prioritized alerts based on impact and urgency
|
||||
|
||||
Uses AI to prioritize alerts based on potential business impact and urgency.
|
||||
"""
|
||||
try:
|
||||
# Verify this is a parent tenant
|
||||
tenant_info = await network_alerts_service.tenant_client.get_tenant(parent_id)
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only parent tenants can access prioritized alerts"
|
||||
)
|
||||
|
||||
# Get prioritized alerts
|
||||
prioritized_alerts = await network_alerts_service.get_prioritized_alerts(parent_id, limit)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'prioritized_alerts': prioritized_alerts,
|
||||
'message': f'Top {len(prioritized_alerts)} prioritized alerts'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get prioritized alerts", parent_id=parent_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get prioritized alerts: {str(e)}")
|
||||
|
||||
|
||||
# Import datetime at runtime to avoid circular imports
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
129
services/tenant/app/api/onboarding.py
Normal file
129
services/tenant/app/api/onboarding.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Onboarding Status API
|
||||
Provides lightweight onboarding status checks by aggregating counts from multiple services
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
import asyncio
|
||||
import httpx
|
||||
import os
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from shared.auth.decorators import get_current_tenant_id_dep
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/onboarding/status", include_tenant_prefix=False))
|
||||
async def get_onboarding_status(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get lightweight onboarding status by fetching counts from each service.
|
||||
|
||||
Returns:
|
||||
- ingredients_count: Number of active ingredients
|
||||
- suppliers_count: Number of active suppliers
|
||||
- recipes_count: Number of active recipes
|
||||
- has_minimum_setup: Boolean indicating if minimum requirements are met
|
||||
- progress_percentage: Overall onboarding progress (0-100)
|
||||
"""
|
||||
try:
|
||||
# Service URLs from environment
|
||||
inventory_url = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
|
||||
suppliers_url = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000")
|
||||
recipes_url = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000")
|
||||
|
||||
|
||||
# Fetch counts from all services in parallel
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
results = await asyncio.gather(
|
||||
client.get(
|
||||
f"{inventory_url}/internal/count",
|
||||
params={"tenant_id": tenant_id}
|
||||
),
|
||||
client.get(
|
||||
f"{suppliers_url}/internal/count",
|
||||
params={"tenant_id": tenant_id}
|
||||
),
|
||||
client.get(
|
||||
f"{recipes_url}/internal/count",
|
||||
params={"tenant_id": tenant_id}
|
||||
),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Extract counts with fallback to 0
|
||||
ingredients_count = 0
|
||||
suppliers_count = 0
|
||||
recipes_count = 0
|
||||
|
||||
if not isinstance(results[0], Exception) and results[0].status_code == 200:
|
||||
ingredients_count = results[0].json().get("count", 0)
|
||||
|
||||
if not isinstance(results[1], Exception) and results[1].status_code == 200:
|
||||
suppliers_count = results[1].json().get("count", 0)
|
||||
|
||||
if not isinstance(results[2], Exception) and results[2].status_code == 200:
|
||||
recipes_count = results[2].json().get("count", 0)
|
||||
|
||||
# Calculate minimum setup requirements
|
||||
# Minimum: 3 ingredients, 1 supplier, 1 recipe
|
||||
has_minimum_ingredients = ingredients_count >= 3
|
||||
has_minimum_suppliers = suppliers_count >= 1
|
||||
has_minimum_recipes = recipes_count >= 1
|
||||
|
||||
has_minimum_setup = all([
|
||||
has_minimum_ingredients,
|
||||
has_minimum_suppliers,
|
||||
has_minimum_recipes
|
||||
])
|
||||
|
||||
# Calculate progress percentage
|
||||
# Each requirement contributes 33.33%
|
||||
progress = 0
|
||||
if has_minimum_ingredients:
|
||||
progress += 33
|
||||
if has_minimum_suppliers:
|
||||
progress += 33
|
||||
if has_minimum_recipes:
|
||||
progress += 34
|
||||
|
||||
return {
|
||||
"ingredients_count": ingredients_count,
|
||||
"suppliers_count": suppliers_count,
|
||||
"recipes_count": recipes_count,
|
||||
"has_minimum_setup": has_minimum_setup,
|
||||
"progress_percentage": progress,
|
||||
"requirements": {
|
||||
"ingredients": {
|
||||
"current": ingredients_count,
|
||||
"minimum": 3,
|
||||
"met": has_minimum_ingredients
|
||||
},
|
||||
"suppliers": {
|
||||
"current": suppliers_count,
|
||||
"minimum": 1,
|
||||
"met": has_minimum_suppliers
|
||||
},
|
||||
"recipes": {
|
||||
"current": recipes_count,
|
||||
"minimum": 1,
|
||||
"met": has_minimum_recipes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get onboarding status", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get onboarding status: {str(e)}"
|
||||
)
|
||||
330
services/tenant/app/api/plans.py
Normal file
330
services/tenant/app/api/plans.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Subscription Plans API
|
||||
Public endpoint for fetching available subscription plans
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
import structlog
|
||||
|
||||
from shared.subscription.plans import (
|
||||
SubscriptionTier,
|
||||
SubscriptionPlanMetadata,
|
||||
PlanPricing,
|
||||
QuotaLimits,
|
||||
PlanFeatures,
|
||||
FeatureCategories,
|
||||
UserFacingFeatures
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/plans", tags=["subscription-plans"])
|
||||
|
||||
|
||||
@router.get("", response_model=Dict[str, Any])
|
||||
async def get_available_plans():
|
||||
"""
|
||||
Get all available subscription plans with complete metadata
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Returns:
|
||||
Dictionary containing plan metadata for all tiers
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"plans": {
|
||||
"starter": {
|
||||
"name": "Starter",
|
||||
"description": "Perfect for small bakeries getting started",
|
||||
"monthly_price": 49.00,
|
||||
"yearly_price": 490.00,
|
||||
"features": [...],
|
||||
"limits": {...}
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
plans_data = {}
|
||||
|
||||
for tier in SubscriptionTier:
|
||||
metadata = SubscriptionPlanMetadata.PLANS[tier]
|
||||
|
||||
# Convert Decimal to float for JSON serialization
|
||||
plans_data[tier.value] = {
|
||||
"name": metadata["name"],
|
||||
"description_key": metadata["description_key"],
|
||||
"tagline_key": metadata["tagline_key"],
|
||||
"popular": metadata["popular"],
|
||||
"monthly_price": float(metadata["monthly_price"]),
|
||||
"yearly_price": float(metadata["yearly_price"]),
|
||||
"trial_days": metadata["trial_days"],
|
||||
"features": metadata["features"],
|
||||
"hero_features": metadata.get("hero_features", []),
|
||||
"roi_badge": metadata.get("roi_badge"),
|
||||
"business_metrics": metadata.get("business_metrics"),
|
||||
"limits": metadata["limits"],
|
||||
"support_key": metadata["support_key"],
|
||||
"recommended_for_key": metadata["recommended_for_key"],
|
||||
"contact_sales": metadata.get("contact_sales", False),
|
||||
"custom_pricing": metadata.get("custom_pricing", False),
|
||||
}
|
||||
|
||||
logger.info("subscription_plans_fetched", tier_count=len(plans_data))
|
||||
|
||||
return {"plans": plans_data}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("failed_to_fetch_plans", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch subscription plans"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier}", response_model=Dict[str, Any])
|
||||
async def get_plan_by_tier(tier: str):
|
||||
"""
|
||||
Get metadata for a specific subscription tier
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Args:
|
||||
tier: Subscription tier (starter, professional, enterprise)
|
||||
|
||||
Returns:
|
||||
Plan metadata for the specified tier
|
||||
|
||||
Raises:
|
||||
404: If tier is not found
|
||||
"""
|
||||
try:
|
||||
# Validate tier
|
||||
tier_enum = SubscriptionTier(tier.lower())
|
||||
|
||||
metadata = SubscriptionPlanMetadata.PLANS[tier_enum]
|
||||
|
||||
plan_data = {
|
||||
"tier": tier_enum.value,
|
||||
"name": metadata["name"],
|
||||
"description_key": metadata["description_key"],
|
||||
"tagline_key": metadata["tagline_key"],
|
||||
"popular": metadata["popular"],
|
||||
"monthly_price": float(metadata["monthly_price"]),
|
||||
"yearly_price": float(metadata["yearly_price"]),
|
||||
"trial_days": metadata["trial_days"],
|
||||
"features": metadata["features"],
|
||||
"hero_features": metadata.get("hero_features", []),
|
||||
"roi_badge": metadata.get("roi_badge"),
|
||||
"business_metrics": metadata.get("business_metrics"),
|
||||
"limits": metadata["limits"],
|
||||
"support_key": metadata["support_key"],
|
||||
"recommended_for_key": metadata["recommended_for_key"],
|
||||
"contact_sales": metadata.get("contact_sales", False),
|
||||
"custom_pricing": metadata.get("custom_pricing", False),
|
||||
}
|
||||
|
||||
logger.info("subscription_plan_fetched", tier=tier)
|
||||
|
||||
return plan_data
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subscription tier '{tier}' not found"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("failed_to_fetch_plan", tier=tier, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch subscription plan"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier}/features")
|
||||
async def get_plan_features(tier: str):
|
||||
"""
|
||||
Get all features available in a subscription tier
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Args:
|
||||
tier: Subscription tier (starter, professional, enterprise)
|
||||
|
||||
Returns:
|
||||
List of feature keys available in the tier
|
||||
"""
|
||||
try:
|
||||
tier_enum = SubscriptionTier(tier.lower())
|
||||
features = PlanFeatures.get_features(tier_enum.value)
|
||||
|
||||
return {
|
||||
"tier": tier_enum.value,
|
||||
"features": features,
|
||||
"feature_count": len(features)
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subscription tier '{tier}' not found"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier}/limits")
|
||||
async def get_plan_limits(tier: str):
|
||||
"""
|
||||
Get all quota limits for a subscription tier
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Args:
|
||||
tier: Subscription tier (starter, professional, enterprise)
|
||||
|
||||
Returns:
|
||||
All quota limits for the tier
|
||||
"""
|
||||
try:
|
||||
tier_enum = SubscriptionTier(tier.lower())
|
||||
|
||||
limits = {
|
||||
"tier": tier_enum.value,
|
||||
"team_and_organization": {
|
||||
"max_users": QuotaLimits.MAX_USERS[tier_enum],
|
||||
"max_locations": QuotaLimits.MAX_LOCATIONS[tier_enum],
|
||||
},
|
||||
"product_and_inventory": {
|
||||
"max_products": QuotaLimits.MAX_PRODUCTS[tier_enum],
|
||||
"max_recipes": QuotaLimits.MAX_RECIPES[tier_enum],
|
||||
"max_suppliers": QuotaLimits.MAX_SUPPLIERS[tier_enum],
|
||||
},
|
||||
"ml_and_analytics": {
|
||||
"training_jobs_per_day": QuotaLimits.TRAINING_JOBS_PER_DAY[tier_enum],
|
||||
"forecast_generation_per_day": QuotaLimits.FORECAST_GENERATION_PER_DAY[tier_enum],
|
||||
"dataset_size_rows": QuotaLimits.DATASET_SIZE_ROWS[tier_enum],
|
||||
"forecast_horizon_days": QuotaLimits.FORECAST_HORIZON_DAYS[tier_enum],
|
||||
"historical_data_access_days": QuotaLimits.HISTORICAL_DATA_ACCESS_DAYS[tier_enum],
|
||||
},
|
||||
"import_export": {
|
||||
"bulk_import_rows": QuotaLimits.BULK_IMPORT_ROWS[tier_enum],
|
||||
"bulk_export_rows": QuotaLimits.BULK_EXPORT_ROWS[tier_enum],
|
||||
},
|
||||
"integrations": {
|
||||
"pos_sync_interval_minutes": QuotaLimits.POS_SYNC_INTERVAL_MINUTES[tier_enum],
|
||||
"api_calls_per_hour": QuotaLimits.API_CALLS_PER_HOUR[tier_enum],
|
||||
"webhook_endpoints": QuotaLimits.WEBHOOK_ENDPOINTS[tier_enum],
|
||||
},
|
||||
"storage": {
|
||||
"file_storage_gb": QuotaLimits.FILE_STORAGE_GB[tier_enum],
|
||||
"report_retention_days": QuotaLimits.REPORT_RETENTION_DAYS[tier_enum],
|
||||
}
|
||||
}
|
||||
|
||||
return limits
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Subscription tier '{tier}' not found"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feature-categories")
|
||||
async def get_feature_categories():
|
||||
"""
|
||||
Get all feature categories with icons and translation keys
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Returns:
|
||||
Dictionary of feature categories
|
||||
"""
|
||||
try:
|
||||
return {
|
||||
"categories": FeatureCategories.CATEGORIES
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("failed_to_fetch_feature_categories", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch feature categories"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feature-descriptions")
|
||||
async def get_feature_descriptions():
|
||||
"""
|
||||
Get user-facing feature descriptions with translation keys
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Returns:
|
||||
Dictionary of feature descriptions mapped by feature key
|
||||
"""
|
||||
try:
|
||||
return {
|
||||
"features": UserFacingFeatures.FEATURE_DISPLAY
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("failed_to_fetch_feature_descriptions", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to fetch feature descriptions"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compare")
|
||||
async def compare_plans():
|
||||
"""
|
||||
Get plan comparison data for all tiers
|
||||
|
||||
**Public endpoint** - No authentication required
|
||||
|
||||
Returns:
|
||||
Comparison matrix of all plans with key features and limits
|
||||
"""
|
||||
try:
|
||||
comparison = {
|
||||
"tiers": ["starter", "professional", "enterprise"],
|
||||
"pricing": {},
|
||||
"key_features": {},
|
||||
"key_limits": {}
|
||||
}
|
||||
|
||||
for tier in SubscriptionTier:
|
||||
metadata = SubscriptionPlanMetadata.PLANS[tier]
|
||||
|
||||
# Pricing
|
||||
comparison["pricing"][tier.value] = {
|
||||
"monthly": float(metadata["monthly_price"]),
|
||||
"yearly": float(metadata["yearly_price"]),
|
||||
"savings_percentage": round(
|
||||
((float(metadata["monthly_price"]) * 12) - float(metadata["yearly_price"])) /
|
||||
(float(metadata["monthly_price"]) * 12) * 100
|
||||
)
|
||||
}
|
||||
|
||||
# Key features (first 10)
|
||||
comparison["key_features"][tier.value] = metadata["features"][:10]
|
||||
|
||||
# Key limits
|
||||
comparison["key_limits"][tier.value] = {
|
||||
"users": metadata["limits"]["users"],
|
||||
"locations": metadata["limits"]["locations"],
|
||||
"products": metadata["limits"]["products"],
|
||||
"forecasts_per_day": metadata["limits"]["forecasts_per_day"],
|
||||
"training_jobs_per_day": QuotaLimits.TRAINING_JOBS_PER_DAY[tier],
|
||||
}
|
||||
|
||||
return comparison
|
||||
|
||||
except Exception as e:
|
||||
logger.error("failed_to_compare_plans", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to generate plan comparison"
|
||||
)
|
||||
1264
services/tenant/app/api/subscription.py
Normal file
1264
services/tenant/app/api/subscription.py
Normal file
File diff suppressed because it is too large
Load Diff
595
services/tenant/app/api/tenant_hierarchy.py
Normal file
595
services/tenant/app/api/tenant_hierarchy.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
Tenant Hierarchy API - Handles parent-child tenant relationships
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path
|
||||
from typing import List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.tenants import (
|
||||
TenantResponse,
|
||||
ChildTenantCreate,
|
||||
BulkChildTenantsCreate,
|
||||
BulkChildTenantsResponse,
|
||||
ChildTenantResponse,
|
||||
TenantHierarchyResponse
|
||||
)
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return EnhancedTenantService(database_manager)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/children", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
@track_endpoint_metrics("tenant_children_list")
|
||||
async def get_tenant_children(
|
||||
tenant_id: UUID = Path(..., description="Parent Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Get all child tenants for a parent tenant.
|
||||
This endpoint returns all active child tenants associated with the specified parent tenant.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant children request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
user_type=current_user.get("type", "user"),
|
||||
is_service=current_user.get("type") == "service",
|
||||
role=current_user.get("role"),
|
||||
service_name=current_user.get("service", "none")
|
||||
)
|
||||
|
||||
# Skip access check for service-to-service calls
|
||||
is_service_call = current_user.get("type") == "service"
|
||||
if not is_service_call:
|
||||
# Verify user has access to the parent tenant
|
||||
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
|
||||
if not access_info.has_access:
|
||||
logger.warning(
|
||||
"Access denied to parent tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to parent tenant"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Service-to-service call - bypassing access check",
|
||||
service=current_user.get("service"),
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
# Get child tenants from repository
|
||||
from app.models.tenants import Tenant
|
||||
async with tenant_service.database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
child_tenants = await tenant_repo.get_child_tenants(str(tenant_id))
|
||||
|
||||
logger.debug(
|
||||
"Get tenant children successful",
|
||||
tenant_id=str(tenant_id),
|
||||
child_count=len(child_tenants)
|
||||
)
|
||||
|
||||
# Convert to plain dicts while still in session to avoid lazy-load issues
|
||||
child_dicts = []
|
||||
for child in child_tenants:
|
||||
# Handle subscription_tier safely - avoid lazy load
|
||||
try:
|
||||
# Try to get subscription_tier if subscriptions are already loaded
|
||||
sub_tier = child.__dict__.get('_subscription_tier_cache', 'enterprise')
|
||||
except:
|
||||
sub_tier = 'enterprise' # Default for enterprise children
|
||||
|
||||
child_dict = {
|
||||
'id': str(child.id),
|
||||
'name': child.name,
|
||||
'subdomain': child.subdomain,
|
||||
'business_type': child.business_type,
|
||||
'business_model': child.business_model,
|
||||
'address': child.address,
|
||||
'city': child.city,
|
||||
'postal_code': child.postal_code,
|
||||
'latitude': child.latitude,
|
||||
'longitude': child.longitude,
|
||||
'phone': child.phone,
|
||||
'email': child.email,
|
||||
'timezone': child.timezone,
|
||||
'owner_id': str(child.owner_id),
|
||||
'parent_tenant_id': str(child.parent_tenant_id) if child.parent_tenant_id else None,
|
||||
'tenant_type': child.tenant_type,
|
||||
'hierarchy_path': child.hierarchy_path,
|
||||
'subscription_tier': sub_tier, # Use the safely retrieved value
|
||||
'ml_model_trained': child.ml_model_trained,
|
||||
'last_training_date': child.last_training_date,
|
||||
'is_active': child.is_active,
|
||||
'is_demo': child.is_demo,
|
||||
'demo_session_id': child.demo_session_id,
|
||||
'created_at': child.created_at,
|
||||
'updated_at': child.updated_at
|
||||
}
|
||||
child_dicts.append(child_dict)
|
||||
|
||||
# Convert to Pydantic models outside the session without from_attributes
|
||||
child_responses = [TenantResponse(**child_dict) for child_dict in child_dicts]
|
||||
return child_responses
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant children failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant children failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/children/count", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_children_count")
|
||||
async def get_tenant_children_count(
|
||||
tenant_id: UUID = Path(..., description="Parent Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Get count of child tenants for a parent tenant.
|
||||
This endpoint returns the number of active child tenants associated with the specified parent tenant.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant children count request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Skip access check for service-to-service calls
|
||||
is_service_call = current_user.get("type") == "service"
|
||||
if not is_service_call:
|
||||
# Verify user has access to the parent tenant
|
||||
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
|
||||
if not access_info.has_access:
|
||||
logger.warning(
|
||||
"Access denied to parent tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to parent tenant"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Service-to-service call - bypassing access check",
|
||||
service=current_user.get("service"),
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
# Get child count from repository
|
||||
from app.models.tenants import Tenant
|
||||
async with tenant_service.database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
child_count = await tenant_repo.get_child_tenant_count(str(tenant_id))
|
||||
|
||||
logger.debug(
|
||||
"Get tenant children count successful",
|
||||
tenant_id=str(tenant_id),
|
||||
child_count=child_count
|
||||
)
|
||||
|
||||
return {
|
||||
"parent_tenant_id": str(tenant_id),
|
||||
"child_count": child_count
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant children count failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant children count failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/hierarchy", include_tenant_prefix=False), response_model=TenantHierarchyResponse)
|
||||
@track_endpoint_metrics("tenant_hierarchy")
|
||||
async def get_tenant_hierarchy(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Get tenant hierarchy information.
|
||||
|
||||
Returns hierarchy metadata for a tenant including:
|
||||
- Tenant type (standalone, parent, child)
|
||||
- Parent tenant ID (if this is a child)
|
||||
- Hierarchy path (materialized path)
|
||||
- Number of child tenants (for parent tenants)
|
||||
- Hierarchy level (depth in the tree)
|
||||
|
||||
This endpoint is used by the authentication layer for hierarchical access control
|
||||
and by enterprise features for network management.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant hierarchy request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
user_type=current_user.get("type", "user"),
|
||||
is_service=current_user.get("type") == "service"
|
||||
)
|
||||
|
||||
# Get tenant from database
|
||||
from app.models.tenants import Tenant
|
||||
async with tenant_service.database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
|
||||
# Get the tenant
|
||||
tenant = await tenant_repo.get(str(tenant_id))
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found"
|
||||
)
|
||||
|
||||
# Skip access check for service-to-service calls
|
||||
is_service_call = current_user.get("type") == "service"
|
||||
if not is_service_call:
|
||||
# Verify user has access to this tenant
|
||||
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
|
||||
if not access_info.has_access:
|
||||
logger.warning(
|
||||
"Access denied to tenant for hierarchy query",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Service-to-service call - bypassing access check",
|
||||
service=current_user.get("service"),
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
# Get child count if this is a parent tenant
|
||||
child_count = 0
|
||||
if tenant.tenant_type in ["parent", "standalone"]:
|
||||
child_count = await tenant_repo.get_child_tenant_count(str(tenant_id))
|
||||
|
||||
# Calculate hierarchy level from hierarchy_path
|
||||
hierarchy_level = 0
|
||||
if tenant.hierarchy_path:
|
||||
# hierarchy_path format: "parent_id" or "parent_id.child_id" or "parent_id.child_id.grandchild_id"
|
||||
hierarchy_level = tenant.hierarchy_path.count('.')
|
||||
|
||||
# Build response
|
||||
hierarchy_info = TenantHierarchyResponse(
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_type=tenant.tenant_type or "standalone",
|
||||
parent_tenant_id=str(tenant.parent_tenant_id) if tenant.parent_tenant_id else None,
|
||||
hierarchy_path=tenant.hierarchy_path,
|
||||
child_count=child_count,
|
||||
hierarchy_level=hierarchy_level
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Get tenant hierarchy successful",
|
||||
tenant_id=str(tenant_id),
|
||||
tenant_type=tenant.tenant_type,
|
||||
parent_tenant_id=str(tenant.parent_tenant_id) if tenant.parent_tenant_id else None,
|
||||
child_count=child_count,
|
||||
hierarchy_level=hierarchy_level
|
||||
)
|
||||
|
||||
return hierarchy_info
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant hierarchy failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant hierarchy failed"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/tenants/{tenant_id}/bulk-children", response_model=BulkChildTenantsResponse)
|
||||
@track_endpoint_metrics("bulk_create_child_tenants")
|
||||
async def bulk_create_child_tenants(
|
||||
request: BulkChildTenantsCreate,
|
||||
tenant_id: str = Path(..., description="Parent tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Bulk create child tenants for enterprise onboarding.
|
||||
|
||||
This endpoint creates multiple child tenants (outlets/branches) for an enterprise parent tenant
|
||||
and establishes the parent-child relationship. It's designed for use during the onboarding flow
|
||||
when an enterprise customer registers their network of locations.
|
||||
|
||||
Features:
|
||||
- Creates child tenants with proper hierarchy
|
||||
- Inherits subscription from parent
|
||||
- Optionally configures distribution routes
|
||||
- Returns detailed success/failure information
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Bulk child tenant creation request received",
|
||||
parent_tenant_id=tenant_id,
|
||||
child_count=len(request.child_tenants),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Verify parent tenant exists and user has access
|
||||
async with tenant_service.database_manager.get_session() as session:
|
||||
from app.models.tenants import Tenant
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
|
||||
parent_tenant = await tenant_repo.get_by_id(tenant_id)
|
||||
if not parent_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent tenant not found"
|
||||
)
|
||||
|
||||
# Verify user has access to parent tenant (owners/admins only)
|
||||
access_info = await tenant_service.verify_user_access(
|
||||
current_user["user_id"],
|
||||
tenant_id
|
||||
)
|
||||
if not access_info.has_access or access_info.role not in ["owner", "admin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only tenant owners/admins can create child tenants"
|
||||
)
|
||||
|
||||
# Verify parent is enterprise tier
|
||||
parent_subscription = await tenant_service.subscription_repo.get_active_subscription(tenant_id)
|
||||
if not parent_subscription or parent_subscription.plan != "enterprise":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only enterprise tier tenants can have child tenants"
|
||||
)
|
||||
|
||||
# Update parent tenant type if it's still standalone
|
||||
if parent_tenant.tenant_type == "standalone":
|
||||
parent_tenant.tenant_type = "parent"
|
||||
parent_tenant.hierarchy_path = str(parent_tenant.id)
|
||||
await session.commit()
|
||||
await session.refresh(parent_tenant)
|
||||
|
||||
# Create child tenants
|
||||
created_tenants = []
|
||||
failed_tenants = []
|
||||
|
||||
for child_data in request.child_tenants:
|
||||
# Create a nested transaction (savepoint) for each child tenant
|
||||
# This allows us to rollback individual child tenant creation without affecting others
|
||||
async with session.begin_nested():
|
||||
try:
|
||||
# Create child tenant with full tenant model fields
|
||||
child_tenant = Tenant(
|
||||
name=child_data.name,
|
||||
subdomain=None, # Child tenants typically don't have subdomains
|
||||
business_type=child_data.business_type or parent_tenant.business_type,
|
||||
business_model=child_data.business_model or "retail_bakery", # Child outlets are typically retail
|
||||
address=child_data.address,
|
||||
city=child_data.city,
|
||||
postal_code=child_data.postal_code,
|
||||
latitude=child_data.latitude,
|
||||
longitude=child_data.longitude,
|
||||
phone=child_data.phone or parent_tenant.phone,
|
||||
email=child_data.email or parent_tenant.email,
|
||||
timezone=child_data.timezone or parent_tenant.timezone,
|
||||
owner_id=parent_tenant.owner_id,
|
||||
parent_tenant_id=parent_tenant.id,
|
||||
tenant_type="child",
|
||||
hierarchy_path=f"{parent_tenant.hierarchy_path}", # Will be updated after flush
|
||||
is_active=True,
|
||||
is_demo=parent_tenant.is_demo,
|
||||
demo_session_id=parent_tenant.demo_session_id,
|
||||
demo_expires_at=parent_tenant.demo_expires_at,
|
||||
metadata_={
|
||||
"location_code": child_data.location_code,
|
||||
"zone": child_data.zone,
|
||||
**(child_data.metadata or {})
|
||||
}
|
||||
)
|
||||
|
||||
session.add(child_tenant)
|
||||
await session.flush() # Get the ID without committing
|
||||
|
||||
# Update hierarchy_path now that we have the child tenant ID
|
||||
child_tenant.hierarchy_path = f"{parent_tenant.hierarchy_path}.{str(child_tenant.id)}"
|
||||
|
||||
# Create TenantLocation record for the child
|
||||
from app.models.tenant_location import TenantLocation
|
||||
location = TenantLocation(
|
||||
tenant_id=child_tenant.id,
|
||||
name=child_data.name,
|
||||
city=child_data.city,
|
||||
address=child_data.address,
|
||||
postal_code=child_data.postal_code,
|
||||
latitude=child_data.latitude,
|
||||
longitude=child_data.longitude,
|
||||
is_active=True,
|
||||
location_type="retail"
|
||||
)
|
||||
session.add(location)
|
||||
|
||||
# Inherit subscription from parent
|
||||
from app.models.tenants import Subscription
|
||||
from sqlalchemy import select
|
||||
parent_subscription_result = await session.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.tenant_id == parent_tenant.id,
|
||||
Subscription.status == "active"
|
||||
)
|
||||
)
|
||||
parent_sub = parent_subscription_result.scalar_one_or_none()
|
||||
|
||||
if parent_sub:
|
||||
child_subscription = Subscription(
|
||||
tenant_id=child_tenant.id,
|
||||
plan=parent_sub.plan,
|
||||
status="active",
|
||||
billing_cycle=parent_sub.billing_cycle,
|
||||
monthly_price=0, # Child tenants don't pay separately
|
||||
trial_ends_at=parent_sub.trial_ends_at
|
||||
)
|
||||
session.add(child_subscription)
|
||||
|
||||
# Commit the nested transaction (savepoint)
|
||||
await session.flush()
|
||||
|
||||
# Refresh objects to get their final state
|
||||
await session.refresh(child_tenant)
|
||||
await session.refresh(location)
|
||||
|
||||
# Build response
|
||||
created_tenants.append(ChildTenantResponse(
|
||||
id=str(child_tenant.id),
|
||||
name=child_tenant.name,
|
||||
subdomain=child_tenant.subdomain,
|
||||
business_type=child_tenant.business_type,
|
||||
business_model=child_tenant.business_model,
|
||||
tenant_type=child_tenant.tenant_type,
|
||||
parent_tenant_id=str(child_tenant.parent_tenant_id),
|
||||
address=child_tenant.address,
|
||||
city=child_tenant.city,
|
||||
postal_code=child_tenant.postal_code,
|
||||
phone=child_tenant.phone,
|
||||
is_active=child_tenant.is_active,
|
||||
subscription_plan="enterprise",
|
||||
ml_model_trained=child_tenant.ml_model_trained,
|
||||
last_training_date=child_tenant.last_training_date,
|
||||
owner_id=str(child_tenant.owner_id),
|
||||
created_at=child_tenant.created_at,
|
||||
location_code=child_data.location_code,
|
||||
zone=child_data.zone,
|
||||
hierarchy_path=child_tenant.hierarchy_path
|
||||
))
|
||||
|
||||
logger.info(
|
||||
"Child tenant created successfully",
|
||||
child_tenant_id=str(child_tenant.id),
|
||||
child_name=child_tenant.name,
|
||||
location_code=child_data.location_code
|
||||
)
|
||||
|
||||
except Exception as child_error:
|
||||
logger.error(
|
||||
"Failed to create child tenant",
|
||||
child_name=child_data.name,
|
||||
error=str(child_error)
|
||||
)
|
||||
failed_tenants.append({
|
||||
"name": child_data.name,
|
||||
"location_code": child_data.location_code,
|
||||
"error": str(child_error)
|
||||
})
|
||||
# Nested transaction will automatically rollback on exception
|
||||
# This only rolls back the current child tenant, not the entire batch
|
||||
|
||||
# Commit all successful child tenant creations
|
||||
await session.commit()
|
||||
|
||||
# TODO: Configure distribution routes if requested
|
||||
distribution_configured = False
|
||||
if request.auto_configure_distribution and len(created_tenants) > 0:
|
||||
try:
|
||||
# This would call the distribution service to set up routes
|
||||
# For now, we'll skip this and just log
|
||||
logger.info(
|
||||
"Distribution route configuration requested",
|
||||
parent_tenant_id=tenant_id,
|
||||
child_count=len(created_tenants)
|
||||
)
|
||||
# distribution_configured = await configure_distribution_routes(...)
|
||||
except Exception as dist_error:
|
||||
logger.warning(
|
||||
"Failed to configure distribution routes",
|
||||
error=str(dist_error)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Bulk child tenant creation completed",
|
||||
parent_tenant_id=tenant_id,
|
||||
created_count=len(created_tenants),
|
||||
failed_count=len(failed_tenants)
|
||||
)
|
||||
|
||||
return BulkChildTenantsResponse(
|
||||
parent_tenant_id=tenant_id,
|
||||
created_count=len(created_tenants),
|
||||
failed_count=len(failed_tenants),
|
||||
created_tenants=created_tenants,
|
||||
failed_tenants=failed_tenants,
|
||||
distribution_configured=distribution_configured
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Bulk child tenant creation failed",
|
||||
parent_tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Bulk child tenant creation failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# Register the router in the main app
|
||||
def register_hierarchy_routes(app):
|
||||
"""Register hierarchy routes with the main application"""
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
# Include the hierarchy routes with proper tenant prefix
|
||||
app.include_router(
|
||||
router,
|
||||
prefix="/api/v1",
|
||||
tags=["tenant-hierarchy"]
|
||||
)
|
||||
628
services/tenant/app/api/tenant_locations.py
Normal file
628
services/tenant/app/api/tenant_locations.py
Normal file
@@ -0,0 +1,628 @@
|
||||
"""
|
||||
Tenant Locations API - Handles tenant location operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.tenant_locations import (
|
||||
TenantLocationCreate,
|
||||
TenantLocationUpdate,
|
||||
TenantLocationResponse,
|
||||
TenantLocationsResponse,
|
||||
TenantLocationTypeFilter
|
||||
)
|
||||
from app.repositories.tenant_location_repository import TenantLocationRepository
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import admin_role_required
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
|
||||
# Dependency injection for tenant location repository
|
||||
async def get_tenant_location_repository():
|
||||
"""Get tenant location repository instance with proper session management"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
|
||||
# Use async context manager properly to ensure session is closed
|
||||
async with database_manager.get_session() as session:
|
||||
yield TenantLocationRepository(session)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create tenant location repository", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/locations", include_tenant_prefix=False), response_model=TenantLocationsResponse)
|
||||
@track_endpoint_metrics("tenant_locations_list")
|
||||
async def get_tenant_locations(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_types: str = Query(None, description="Comma-separated list of location types to filter"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Get all locations for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant to get locations for
|
||||
location_types: Optional comma-separated list of location types to filter (e.g., "central_production,retail_outlet")
|
||||
is_active: Optional filter for active locations only
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant locations request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
location_types=location_types,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
# Check that the user has access to this tenant
|
||||
# This would typically be checked via access control middleware
|
||||
# For now, we'll trust the gateway has validated tenant access
|
||||
|
||||
locations = []
|
||||
|
||||
if location_types:
|
||||
# Filter by specific location types
|
||||
types_list = [t.strip() for t in location_types.split(",")]
|
||||
locations = await location_repo.get_locations_by_tenant_with_type(str(tenant_id), types_list)
|
||||
elif is_active is True:
|
||||
# Get only active locations
|
||||
locations = await location_repo.get_active_locations_by_tenant(str(tenant_id))
|
||||
elif is_active is False:
|
||||
# Get only inactive locations (by getting all and filtering in memory - not efficient but functional)
|
||||
all_locations = await location_repo.get_locations_by_tenant(str(tenant_id))
|
||||
locations = [loc for loc in all_locations if not loc.is_active]
|
||||
else:
|
||||
# Get all locations
|
||||
locations = await location_repo.get_locations_by_tenant(str(tenant_id))
|
||||
|
||||
logger.debug(
|
||||
"Get tenant locations successful",
|
||||
tenant_id=str(tenant_id),
|
||||
location_count=len(locations)
|
||||
)
|
||||
|
||||
# Convert to response format - handle metadata field to avoid SQLAlchemy conflicts
|
||||
location_responses = []
|
||||
for loc in locations:
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(loc.id),
|
||||
'tenant_id': str(loc.tenant_id),
|
||||
'name': loc.name,
|
||||
'location_type': loc.location_type,
|
||||
'address': loc.address,
|
||||
'city': loc.city,
|
||||
'postal_code': loc.postal_code,
|
||||
'latitude': loc.latitude,
|
||||
'longitude': loc.longitude,
|
||||
'contact_person': loc.contact_person,
|
||||
'contact_phone': loc.contact_phone,
|
||||
'contact_email': loc.contact_email,
|
||||
'is_active': loc.is_active,
|
||||
'delivery_windows': loc.delivery_windows,
|
||||
'operational_hours': loc.operational_hours,
|
||||
'capacity': loc.capacity,
|
||||
'max_delivery_radius_km': loc.max_delivery_radius_km,
|
||||
'delivery_schedule_config': loc.delivery_schedule_config,
|
||||
'metadata': loc.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': loc.created_at,
|
||||
'updated_at': loc.updated_at
|
||||
}
|
||||
location_responses.append(TenantLocationResponse.model_validate(loc_dict))
|
||||
|
||||
return TenantLocationsResponse(
|
||||
locations=location_responses,
|
||||
total=len(location_responses)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant locations failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant locations failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False), response_model=TenantLocationResponse)
|
||||
@track_endpoint_metrics("tenant_location_get")
|
||||
async def get_tenant_location(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_id: UUID = Path(..., description="Location ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Get a specific location for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant
|
||||
location_id: ID of the location to retrieve
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Get the specific location
|
||||
location = await location_repo.get_location_by_id(str(location_id))
|
||||
|
||||
if not location:
|
||||
logger.warning(
|
||||
"Location not found",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
# Verify that the location belongs to the specified tenant
|
||||
if str(location.tenant_id) != str(tenant_id):
|
||||
logger.warning(
|
||||
"Location does not belong to tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
location_tenant_id=str(location.tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Get tenant location successful",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(location.id),
|
||||
'tenant_id': str(location.tenant_id),
|
||||
'name': location.name,
|
||||
'location_type': location.location_type,
|
||||
'address': location.address,
|
||||
'city': location.city,
|
||||
'postal_code': location.postal_code,
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'contact_person': location.contact_person,
|
||||
'contact_phone': location.contact_phone,
|
||||
'contact_email': location.contact_email,
|
||||
'is_active': location.is_active,
|
||||
'delivery_windows': location.delivery_windows,
|
||||
'operational_hours': location.operational_hours,
|
||||
'capacity': location.capacity,
|
||||
'max_delivery_radius_km': location.max_delivery_radius_km,
|
||||
'delivery_schedule_config': location.delivery_schedule_config,
|
||||
'metadata': location.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': location.created_at,
|
||||
'updated_at': location.updated_at
|
||||
}
|
||||
return TenantLocationResponse.model_validate(loc_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/locations", include_tenant_prefix=False), response_model=TenantLocationResponse)
|
||||
@admin_role_required
|
||||
async def create_tenant_location(
|
||||
location_data: TenantLocationCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Create a new location for a tenant.
|
||||
Requires admin or owner privileges.
|
||||
|
||||
Args:
|
||||
location_data: Location data to create
|
||||
tenant_id: ID of the tenant to create location for
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Create tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Verify that the tenant_id in the path matches the one in the data
|
||||
if str(tenant_id) != location_data.tenant_id:
|
||||
logger.warning(
|
||||
"Tenant ID mismatch",
|
||||
path_tenant_id=str(tenant_id),
|
||||
data_tenant_id=location_data.tenant_id,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID in path does not match data"
|
||||
)
|
||||
|
||||
# Prepare location data by excluding unset values
|
||||
location_dict = location_data.model_dump(exclude_unset=True)
|
||||
# Ensure tenant_id comes from the path for security
|
||||
location_dict['tenant_id'] = str(tenant_id)
|
||||
|
||||
created_location = await location_repo.create_location(location_dict)
|
||||
|
||||
logger.info(
|
||||
"Created tenant location successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(created_location.id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(created_location.id),
|
||||
'tenant_id': str(created_location.tenant_id),
|
||||
'name': created_location.name,
|
||||
'location_type': created_location.location_type,
|
||||
'address': created_location.address,
|
||||
'city': created_location.city,
|
||||
'postal_code': created_location.postal_code,
|
||||
'latitude': created_location.latitude,
|
||||
'longitude': created_location.longitude,
|
||||
'contact_person': created_location.contact_person,
|
||||
'contact_phone': created_location.contact_phone,
|
||||
'contact_email': created_location.contact_email,
|
||||
'is_active': created_location.is_active,
|
||||
'delivery_windows': created_location.delivery_windows,
|
||||
'operational_hours': created_location.operational_hours,
|
||||
'capacity': created_location.capacity,
|
||||
'max_delivery_radius_km': created_location.max_delivery_radius_km,
|
||||
'delivery_schedule_config': created_location.delivery_schedule_config,
|
||||
'metadata': created_location.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': created_location.created_at,
|
||||
'updated_at': created_location.updated_at
|
||||
}
|
||||
return TenantLocationResponse.model_validate(loc_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Create tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Create tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False), response_model=TenantLocationResponse)
|
||||
@admin_role_required
|
||||
async def update_tenant_location(
|
||||
update_data: TenantLocationUpdate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_id: UUID = Path(..., description="Location ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Update a tenant location.
|
||||
Requires admin or owner privileges.
|
||||
|
||||
Args:
|
||||
update_data: Location data to update
|
||||
tenant_id: ID of the tenant
|
||||
location_id: ID of the location to update
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Update tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Check if the location exists and belongs to the tenant
|
||||
existing_location = await location_repo.get_location_by_id(str(location_id))
|
||||
if not existing_location:
|
||||
logger.warning(
|
||||
"Location not found for update",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
if str(existing_location.tenant_id) != str(tenant_id):
|
||||
logger.warning(
|
||||
"Location does not belong to tenant for update",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
location_tenant_id=str(existing_location.tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
# Prepare update data by excluding unset values
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
updated_location = await location_repo.update_location(str(location_id), update_dict)
|
||||
|
||||
if not updated_location:
|
||||
logger.error(
|
||||
"Failed to update location (not found after verification)",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated tenant location successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(updated_location.id),
|
||||
'tenant_id': str(updated_location.tenant_id),
|
||||
'name': updated_location.name,
|
||||
'location_type': updated_location.location_type,
|
||||
'address': updated_location.address,
|
||||
'city': updated_location.city,
|
||||
'postal_code': updated_location.postal_code,
|
||||
'latitude': updated_location.latitude,
|
||||
'longitude': updated_location.longitude,
|
||||
'contact_person': updated_location.contact_person,
|
||||
'contact_phone': updated_location.contact_phone,
|
||||
'contact_email': updated_location.contact_email,
|
||||
'is_active': updated_location.is_active,
|
||||
'delivery_windows': updated_location.delivery_windows,
|
||||
'operational_hours': updated_location.operational_hours,
|
||||
'capacity': updated_location.capacity,
|
||||
'max_delivery_radius_km': updated_location.max_delivery_radius_km,
|
||||
'delivery_schedule_config': updated_location.delivery_schedule_config,
|
||||
'metadata': updated_location.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': updated_location.created_at,
|
||||
'updated_at': updated_location.updated_at
|
||||
}
|
||||
return TenantLocationResponse.model_validate(loc_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Update tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Update tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False))
|
||||
@admin_role_required
|
||||
async def delete_tenant_location(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_id: UUID = Path(..., description="Location ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Delete a tenant location.
|
||||
Requires admin or owner privileges.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant
|
||||
location_id: ID of the location to delete
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Delete tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Check if the location exists and belongs to the tenant
|
||||
existing_location = await location_repo.get_location_by_id(str(location_id))
|
||||
if not existing_location:
|
||||
logger.warning(
|
||||
"Location not found for deletion",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
if str(existing_location.tenant_id) != str(tenant_id):
|
||||
logger.warning(
|
||||
"Location does not belong to tenant for deletion",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
location_tenant_id=str(existing_location.tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
deleted = await location_repo.delete_location(str(location_id))
|
||||
|
||||
if not deleted:
|
||||
logger.warning(
|
||||
"Location not found for deletion (race condition)",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Deleted tenant location successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Location deleted successfully",
|
||||
"location_id": str(location_id)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Delete tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Delete tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/locations/type/{location_type}", include_tenant_prefix=False), response_model=TenantLocationsResponse)
|
||||
@track_endpoint_metrics("tenant_locations_by_type")
|
||||
async def get_tenant_locations_by_type(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_type: str = Path(..., description="Location type to filter by", pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$'),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Get all locations of a specific type for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant to get locations for
|
||||
location_type: Type of location to filter by
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant locations by type request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_type=location_type,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Use the method that returns multiple locations by types
|
||||
location_list = await location_repo.get_locations_by_tenant_with_type(str(tenant_id), [location_type])
|
||||
|
||||
logger.debug(
|
||||
"Get tenant locations by type successful",
|
||||
tenant_id=str(tenant_id),
|
||||
location_type=location_type,
|
||||
location_count=len(location_list)
|
||||
)
|
||||
|
||||
# Convert to response format - handle metadata field to avoid SQLAlchemy conflicts
|
||||
location_responses = []
|
||||
for loc in location_list:
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(loc.id),
|
||||
'tenant_id': str(loc.tenant_id),
|
||||
'name': loc.name,
|
||||
'location_type': loc.location_type,
|
||||
'address': loc.address,
|
||||
'city': loc.city,
|
||||
'postal_code': loc.postal_code,
|
||||
'latitude': loc.latitude,
|
||||
'longitude': loc.longitude,
|
||||
'contact_person': loc.contact_person,
|
||||
'contact_phone': loc.contact_phone,
|
||||
'contact_email': loc.contact_email,
|
||||
'is_active': loc.is_active,
|
||||
'delivery_windows': loc.delivery_windows,
|
||||
'operational_hours': loc.operational_hours,
|
||||
'capacity': loc.capacity,
|
||||
'max_delivery_radius_km': loc.max_delivery_radius_km,
|
||||
'delivery_schedule_config': loc.delivery_schedule_config,
|
||||
'metadata': loc.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': loc.created_at,
|
||||
'updated_at': loc.updated_at
|
||||
}
|
||||
location_responses.append(TenantLocationResponse.model_validate(loc_dict))
|
||||
|
||||
return TenantLocationsResponse(
|
||||
locations=location_responses,
|
||||
total=len(location_responses)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant locations by type failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_type=location_type,
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant locations by type failed"
|
||||
)
|
||||
483
services/tenant/app/api/tenant_members.py
Normal file
483
services/tenant/app/api/tenant_members.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Tenant Member Management API - ATOMIC operations
|
||||
Handles team member CRUD operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from typing import List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.tenants import TenantMemberResponse, AddMemberWithUserCreate, TenantResponse
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return EnhancedTenantService(database_manager)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/members/with-user", include_tenant_prefix=False), response_model=TenantMemberResponse)
|
||||
@track_endpoint_metrics("tenant_add_member_with_user_creation")
|
||||
async def add_team_member_with_user_creation(
|
||||
member_data: AddMemberWithUserCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Add a team member to tenant with optional user creation (pilot phase).
|
||||
|
||||
This endpoint supports two modes:
|
||||
1. Adding an existing user: Set user_id and create_user=False
|
||||
2. Creating a new user: Set create_user=True and provide email, full_name, password
|
||||
|
||||
In pilot phase, this allows owners to directly create users with passwords.
|
||||
In production, this will be replaced with an invitation-based flow.
|
||||
"""
|
||||
try:
|
||||
# CRITICAL: Check subscription limit before adding user
|
||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||
|
||||
limit_service = SubscriptionLimitService()
|
||||
limit_check = await limit_service.can_add_user(str(tenant_id))
|
||||
|
||||
if not limit_check.get('can_add', False):
|
||||
logger.warning(
|
||||
"User limit exceeded",
|
||||
tenant_id=str(tenant_id),
|
||||
current=limit_check.get('current_count'),
|
||||
max=limit_check.get('max_allowed'),
|
||||
reason=limit_check.get('reason')
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"error": "user_limit_exceeded",
|
||||
"message": limit_check.get('reason', 'User limit exceeded'),
|
||||
"current_count": limit_check.get('current_count'),
|
||||
"max_allowed": limit_check.get('max_allowed'),
|
||||
"upgrade_required": True
|
||||
}
|
||||
)
|
||||
|
||||
user_id_to_add = member_data.user_id
|
||||
|
||||
# If create_user is True, create the user first via auth service
|
||||
if member_data.create_user:
|
||||
logger.info(
|
||||
"Creating new user before adding to tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
email=member_data.email,
|
||||
requested_by=current_user["user_id"]
|
||||
)
|
||||
|
||||
# Call auth service to create user
|
||||
from shared.clients.auth_client import AuthServiceClient
|
||||
from app.core.config import settings
|
||||
|
||||
auth_client = AuthServiceClient(settings)
|
||||
|
||||
# Map tenant role to user role
|
||||
# tenant roles: admin, member, viewer
|
||||
# user roles: admin, manager, user
|
||||
user_role_map = {
|
||||
"admin": "admin",
|
||||
"member": "manager",
|
||||
"viewer": "user"
|
||||
}
|
||||
user_role = user_role_map.get(member_data.role, "user")
|
||||
|
||||
try:
|
||||
user_create_data = {
|
||||
"email": member_data.email,
|
||||
"full_name": member_data.full_name,
|
||||
"password": member_data.password,
|
||||
"phone": member_data.phone,
|
||||
"role": user_role,
|
||||
"language": member_data.language or "es",
|
||||
"timezone": member_data.timezone or "Europe/Madrid"
|
||||
}
|
||||
|
||||
created_user = await auth_client.create_user_by_owner(user_create_data)
|
||||
user_id_to_add = created_user.get("id")
|
||||
|
||||
logger.info(
|
||||
"User created successfully",
|
||||
user_id=user_id_to_add,
|
||||
email=member_data.email,
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
except Exception as auth_error:
|
||||
logger.error(
|
||||
"Failed to create user via auth service",
|
||||
error=str(auth_error),
|
||||
email=member_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create user account: {str(auth_error)}"
|
||||
)
|
||||
|
||||
# Add the user (existing or newly created) to the tenant
|
||||
result = await tenant_service.add_team_member(
|
||||
str(tenant_id),
|
||||
user_id_to_add,
|
||||
member_data.role,
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Team member added successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=user_id_to_add,
|
||||
role=member_data.role,
|
||||
user_was_created=member_data.create_user
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Add team member with user creation failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to add team member"
|
||||
)
|
||||
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/members", include_tenant_prefix=False), response_model=TenantMemberResponse)
|
||||
@track_endpoint_metrics("tenant_add_member")
|
||||
async def add_team_member(
|
||||
user_id: str,
|
||||
role: str,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Add an existing team member to tenant (legacy endpoint)"""
|
||||
|
||||
try:
|
||||
# CRITICAL: Check subscription limit before adding user
|
||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||
|
||||
limit_service = SubscriptionLimitService()
|
||||
limit_check = await limit_service.can_add_user(str(tenant_id))
|
||||
|
||||
if not limit_check.get('can_add', False):
|
||||
logger.warning(
|
||||
"User limit exceeded",
|
||||
tenant_id=str(tenant_id),
|
||||
current=limit_check.get('current_count'),
|
||||
max=limit_check.get('max_allowed'),
|
||||
reason=limit_check.get('reason')
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"error": "user_limit_exceeded",
|
||||
"message": limit_check.get('reason', 'User limit exceeded'),
|
||||
"current_count": limit_check.get('current_count'),
|
||||
"max_allowed": limit_check.get('max_allowed'),
|
||||
"upgrade_required": True
|
||||
}
|
||||
)
|
||||
|
||||
result = await tenant_service.add_team_member(
|
||||
str(tenant_id),
|
||||
user_id,
|
||||
role,
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Team member added successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=user_id,
|
||||
role=role
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Add team member failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to add team member"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/members", include_tenant_prefix=False), response_model=List[TenantMemberResponse])
|
||||
async def get_team_members(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
active_only: bool = Query(True, description="Only return active members"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get all team members for a tenant with enhanced filtering"""
|
||||
|
||||
try:
|
||||
members = await tenant_service.get_team_members(
|
||||
str(tenant_id),
|
||||
current_user["user_id"],
|
||||
active_only=active_only
|
||||
)
|
||||
return members
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get team members failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get team members"
|
||||
)
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}/members/{member_user_id}/role", include_tenant_prefix=False), response_model=TenantMemberResponse)
|
||||
@track_endpoint_metrics("tenant_update_member_role")
|
||||
async def update_member_role(
|
||||
new_role: str,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
member_user_id: str = Path(..., description="Member user ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Update team member role with enhanced permission validation"""
|
||||
|
||||
try:
|
||||
result = await tenant_service.update_member_role(
|
||||
str(tenant_id),
|
||||
member_user_id,
|
||||
new_role,
|
||||
current_user["user_id"]
|
||||
)
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Update member role failed",
|
||||
tenant_id=str(tenant_id),
|
||||
member_user_id=member_user_id,
|
||||
new_role=new_role,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update member role"
|
||||
)
|
||||
|
||||
@router.delete(route_builder.build_base_route("{tenant_id}/members/{member_user_id}", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_remove_member")
|
||||
async def remove_team_member(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
member_user_id: str = Path(..., description="Member user ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Remove team member from tenant with enhanced validation"""
|
||||
|
||||
try:
|
||||
success = await tenant_service.remove_team_member(
|
||||
str(tenant_id),
|
||||
member_user_id,
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Team member removed successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to remove team member"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Remove team member failed",
|
||||
tenant_id=str(tenant_id),
|
||||
member_user_id=member_user_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to remove team member"
|
||||
)
|
||||
|
||||
@router.delete(route_builder.build_base_route("user/{user_id}/memberships", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("user_memberships_delete")
|
||||
async def delete_user_memberships(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Delete all tenant memberships for a user.
|
||||
Used by auth service when deleting a user account.
|
||||
Only accessible by internal services.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Delete user memberships request received",
|
||||
user_id=user_id,
|
||||
requesting_service=current_user.get("service", "unknown"),
|
||||
is_service=current_user.get("type") == "service"
|
||||
)
|
||||
|
||||
# Only allow internal service calls
|
||||
if current_user.get("type") != "service":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This endpoint is only accessible to internal services"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await tenant_service.delete_user_memberships(user_id)
|
||||
|
||||
logger.info(
|
||||
"User memberships deleted successfully",
|
||||
user_id=user_id,
|
||||
deleted_count=result.get("deleted_count"),
|
||||
total_memberships=result.get("total_memberships")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "User memberships deleted successfully",
|
||||
"summary": result
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Delete user memberships failed",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete user memberships"
|
||||
)
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/transfer-ownership", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
@track_endpoint_metrics("tenant_transfer_ownership")
|
||||
async def transfer_ownership(
|
||||
new_owner_id: str,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Transfer tenant ownership to another admin.
|
||||
Only the current owner or internal services can perform this action.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Transfer ownership request received",
|
||||
tenant_id=str(tenant_id),
|
||||
new_owner_id=new_owner_id,
|
||||
requesting_user=current_user.get("user_id"),
|
||||
is_service=current_user.get("type") == "service"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get current tenant to find current owner
|
||||
tenant_info = await tenant_service.get_tenant_by_id(str(tenant_id))
|
||||
if not tenant_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
current_owner_id = tenant_info.owner_id
|
||||
|
||||
result = await tenant_service.transfer_tenant_ownership(
|
||||
str(tenant_id),
|
||||
current_owner_id,
|
||||
new_owner_id,
|
||||
requesting_user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Ownership transferred successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
from_owner=current_owner_id,
|
||||
to_owner=new_owner_id
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Transfer ownership failed",
|
||||
tenant_id=str(tenant_id),
|
||||
new_owner_id=new_owner_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to transfer ownership"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/admins", include_tenant_prefix=False), response_model=List[TenantMemberResponse])
|
||||
@track_endpoint_metrics("tenant_get_admins")
|
||||
async def get_tenant_admins(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""
|
||||
Get all admins (owner + admins) for a tenant.
|
||||
Used by auth service to check for other admins before tenant deletion.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Get tenant admins request received",
|
||||
tenant_id=str(tenant_id),
|
||||
requesting_user=current_user.get("user_id"),
|
||||
is_service=current_user.get("type") == "service"
|
||||
)
|
||||
|
||||
try:
|
||||
admins = await tenant_service.get_tenant_admins(str(tenant_id))
|
||||
|
||||
logger.info(
|
||||
"Retrieved tenant admins",
|
||||
tenant_id=str(tenant_id),
|
||||
admin_count=len(admins)
|
||||
)
|
||||
|
||||
return admins
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant admins failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get tenant admins"
|
||||
)
|
||||
734
services/tenant/app/api/tenant_operations.py
Normal file
734
services/tenant/app/api/tenant_operations.py
Normal file
@@ -0,0 +1,734 @@
|
||||
"""
|
||||
Tenant Operations API - BUSINESS operations
|
||||
Handles complex tenant operations, registration, search, and analytics
|
||||
|
||||
NOTE: All subscription-related endpoints have been moved to subscription.py
|
||||
as part of the architecture redesign for better separation of concerns.
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.tenants import (
|
||||
BakeryRegistration, TenantResponse, TenantAccessResponse,
|
||||
TenantSearchRequest
|
||||
)
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.models import AuditLog
|
||||
from shared.auth.decorators import (
|
||||
get_current_user_dep,
|
||||
require_admin_role_dep
|
||||
)
|
||||
from app.core.database import get_db
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from shared.auth.access_control import owner_role_required, admin_role_required
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
from shared.config.base import is_internal_service
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
# Initialize audit logger
|
||||
audit_logger = create_audit_logger("tenant-service", AuditLog)
|
||||
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return EnhancedTenantService(database_manager)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
def get_payment_service():
|
||||
try:
|
||||
return PaymentService()
|
||||
except Exception as e:
|
||||
logger.error("Failed to create payment service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Payment service initialization failed")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TENANT REGISTRATION & ACCESS OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.post(route_builder.build_base_route("register", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
async def register_bakery(
|
||||
bakery_data: BakeryRegistration,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service),
|
||||
payment_service: PaymentService = Depends(get_payment_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Register a new bakery/tenant with enhanced validation and features"""
|
||||
|
||||
try:
|
||||
coupon_validation = None
|
||||
success = None
|
||||
discount = None
|
||||
error = None
|
||||
|
||||
result = await tenant_service.create_bakery(
|
||||
bakery_data,
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
tenant_id = result.id
|
||||
|
||||
if bakery_data.link_existing_subscription and bakery_data.subscription_id:
|
||||
logger.info("Linking existing subscription to new tenant",
|
||||
tenant_id=tenant_id,
|
||||
subscription_id=bakery_data.subscription_id,
|
||||
user_id=current_user["user_id"])
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService(db)
|
||||
|
||||
linking_result = await subscription_service.link_subscription_to_tenant(
|
||||
subscription_id=bakery_data.subscription_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"]
|
||||
)
|
||||
|
||||
logger.info("Subscription linked successfully during tenant registration",
|
||||
tenant_id=tenant_id,
|
||||
subscription_id=bakery_data.subscription_id)
|
||||
|
||||
except Exception as linking_error:
|
||||
logger.error("Error linking subscription during tenant registration",
|
||||
tenant_id=tenant_id,
|
||||
subscription_id=bakery_data.subscription_id,
|
||||
error=str(linking_error))
|
||||
|
||||
elif bakery_data.coupon_code:
|
||||
from app.services.coupon_service import CouponService
|
||||
|
||||
coupon_service = CouponService(db)
|
||||
coupon_validation = await coupon_service.validate_coupon_code(
|
||||
bakery_data.coupon_code,
|
||||
tenant_id
|
||||
)
|
||||
|
||||
if not coupon_validation["valid"]:
|
||||
logger.warning(
|
||||
"Invalid coupon code provided during registration",
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
error=coupon_validation["error_message"]
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=coupon_validation["error_message"]
|
||||
)
|
||||
|
||||
success, discount, error = await coupon_service.redeem_coupon(
|
||||
bakery_data.coupon_code,
|
||||
tenant_id,
|
||||
base_trial_days=0
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info("Coupon redeemed during registration",
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
tenant_id=tenant_id)
|
||||
else:
|
||||
logger.warning("Failed to redeem coupon during registration",
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
error=error)
|
||||
else:
|
||||
try:
|
||||
from app.repositories.subscription_repository import SubscriptionRepository
|
||||
from app.models.tenants import Subscription
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.core.config import settings
|
||||
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
async with database_manager.get_session() as session:
|
||||
subscription_repo = SubscriptionRepository(Subscription, session)
|
||||
|
||||
existing_subscription = await subscription_repo.get_by_tenant_id(str(result.id))
|
||||
|
||||
if existing_subscription:
|
||||
logger.info(
|
||||
"Tenant already has an active subscription, skipping default subscription creation",
|
||||
tenant_id=str(result.id),
|
||||
existing_plan=existing_subscription.plan,
|
||||
subscription_id=str(existing_subscription.id)
|
||||
)
|
||||
else:
|
||||
trial_end_date = datetime.now(timezone.utc)
|
||||
next_billing_date = trial_end_date
|
||||
|
||||
await subscription_repo.create_subscription({
|
||||
"tenant_id": str(result.id),
|
||||
"plan": "starter",
|
||||
"status": "trial",
|
||||
"billing_cycle": "monthly",
|
||||
"next_billing_date": next_billing_date,
|
||||
"trial_ends_at": trial_end_date
|
||||
})
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Default subscription created for new tenant",
|
||||
tenant_id=str(result.id),
|
||||
plan="starter",
|
||||
trial_days=0
|
||||
)
|
||||
except Exception as subscription_error:
|
||||
logger.error(
|
||||
"Failed to create default subscription for tenant",
|
||||
tenant_id=str(result.id),
|
||||
error=str(subscription_error)
|
||||
)
|
||||
|
||||
if coupon_validation and coupon_validation["valid"]:
|
||||
from app.core.config import settings
|
||||
from app.services.coupon_service import CouponService
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
|
||||
async with database_manager.get_session() as session:
|
||||
coupon_service = CouponService(session)
|
||||
success, discount, error = await coupon_service.redeem_coupon(
|
||||
bakery_data.coupon_code,
|
||||
result.id,
|
||||
base_trial_days=0
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Coupon redeemed successfully",
|
||||
tenant_id=result.id,
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
discount=discount
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to redeem coupon after registration",
|
||||
tenant_id=result.id,
|
||||
coupon_code=bakery_data.coupon_code,
|
||||
error=error
|
||||
)
|
||||
|
||||
logger.info("Bakery registered successfully",
|
||||
name=bakery_data.name,
|
||||
owner_email=current_user.get('email'),
|
||||
tenant_id=result.id,
|
||||
coupon_applied=bakery_data.coupon_code is not None)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Bakery registration failed",
|
||||
name=bakery_data.name,
|
||||
owner_id=current_user["user_id"],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Bakery registration failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/my-access", include_tenant_prefix=False), response_model=TenantAccessResponse)
|
||||
async def get_current_user_tenant_access(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get current user's access to tenant with role and permissions"""
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
tenant_service = EnhancedTenantService(database_manager)
|
||||
|
||||
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
|
||||
return access_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Current user access verification failed",
|
||||
user_id=current_user["user_id"],
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Access verification failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/access/{user_id}", include_tenant_prefix=False), response_model=TenantAccessResponse)
|
||||
async def verify_tenant_access(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
user_id: str = Path(..., description="User ID")
|
||||
):
|
||||
"""Verify if user has access to tenant - Enhanced version with detailed permissions"""
|
||||
|
||||
if is_internal_service(user_id):
|
||||
logger.info("Service access granted", service=user_id, tenant_id=str(tenant_id))
|
||||
return TenantAccessResponse(
|
||||
has_access=True,
|
||||
role="service",
|
||||
permissions=["read", "write"]
|
||||
)
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
tenant_service = EnhancedTenantService(database_manager)
|
||||
|
||||
access_info = await tenant_service.verify_user_access(user_id, str(tenant_id))
|
||||
return access_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Access verification failed",
|
||||
user_id=user_id,
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Access verification failed"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TENANT SEARCH & DISCOVERY OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.get(route_builder.build_base_route("subdomain/{subdomain}", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
@track_endpoint_metrics("tenant_get_by_subdomain")
|
||||
async def get_tenant_by_subdomain(
|
||||
subdomain: str = Path(..., description="Tenant subdomain"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get tenant by subdomain with enhanced validation"""
|
||||
|
||||
tenant = await tenant_service.get_tenant_by_subdomain(subdomain)
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
access = await tenant_service.verify_user_access(current_user["user_id"], tenant.id)
|
||||
if not access.has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant"
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("user/{user_id}/owned", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
async def get_user_owned_tenants(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get all tenants owned by a user with enhanced data"""
|
||||
|
||||
user_role = current_user.get('role', '').lower()
|
||||
|
||||
is_demo_user = current_user.get("is_demo", False) and user_id == "demo-user"
|
||||
|
||||
if user_id != current_user["user_id"] and not is_demo_user and user_role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only access your own tenants"
|
||||
)
|
||||
|
||||
if current_user.get("is_demo", False):
|
||||
demo_session_id = current_user.get("demo_session_id")
|
||||
demo_account_type = current_user.get("demo_account_type", "")
|
||||
|
||||
if demo_session_id:
|
||||
logger.info("Fetching virtual tenants for demo session",
|
||||
demo_session_id=demo_session_id,
|
||||
demo_account_type=demo_account_type)
|
||||
|
||||
virtual_tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type)
|
||||
return virtual_tenants
|
||||
else:
|
||||
virtual_tenants = await tenant_service.get_demo_tenants_by_session_type(
|
||||
demo_account_type,
|
||||
str(current_user["user_id"])
|
||||
)
|
||||
return virtual_tenants
|
||||
|
||||
actual_user_id = current_user["user_id"] if is_demo_user else user_id
|
||||
tenants = await tenant_service.get_user_tenants(actual_user_id)
|
||||
return tenants
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("search", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
@track_endpoint_metrics("tenant_search")
|
||||
async def search_tenants(
|
||||
search_term: str = Query(..., description="Search term"),
|
||||
business_type: Optional[str] = Query(None, description="Business type filter"),
|
||||
city: Optional[str] = Query(None, description="City filter"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Search tenants with advanced filters and pagination"""
|
||||
|
||||
tenants = await tenant_service.search_tenants(
|
||||
search_term=search_term,
|
||||
business_type=business_type,
|
||||
city=city,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return tenants
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("nearby", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
@track_endpoint_metrics("tenant_get_nearby")
|
||||
async def get_nearby_tenants(
|
||||
latitude: float = Query(..., description="Latitude coordinate"),
|
||||
longitude: float = Query(..., description="Longitude coordinate"),
|
||||
radius_km: float = Query(10.0, ge=0.1, le=100.0, description="Search radius in kilometers"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of results"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get tenants near a geographic location with enhanced geospatial search"""
|
||||
|
||||
tenants = await tenant_service.get_tenants_near_location(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
radius_km=radius_km,
|
||||
limit=limit
|
||||
)
|
||||
return tenants
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("users/{user_id}", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
@track_endpoint_metrics("tenant_get_user_tenants")
|
||||
async def get_user_tenants(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get all tenants owned by a user - Fixed endpoint for frontend"""
|
||||
|
||||
is_demo_user = current_user.get("is_demo", False)
|
||||
is_service_account = current_user.get("type") == "service"
|
||||
user_role = current_user.get('role', '').lower()
|
||||
|
||||
if user_id != current_user["user_id"] and not is_service_account and not (is_demo_user and user_id == "demo-user") and user_role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only access your own tenants"
|
||||
)
|
||||
|
||||
try:
|
||||
tenants = await tenant_service.get_user_tenants(user_id)
|
||||
logger.info("Retrieved user tenants", user_id=user_id, tenant_count=len(tenants))
|
||||
return tenants
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Get user tenants failed", user_id=user_id, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get user tenants"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("members/user/{user_id}", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_get_user_memberships")
|
||||
async def get_user_memberships(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get all tenant memberships for a user (for authentication service)"""
|
||||
|
||||
is_demo_user = current_user.get("is_demo", False)
|
||||
is_service_account = current_user.get("type") == "service"
|
||||
user_role = current_user.get('role', '').lower()
|
||||
|
||||
if user_id != current_user["user_id"] and not is_service_account and not (is_demo_user and user_id == "demo-user") and user_role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only access your own memberships"
|
||||
)
|
||||
|
||||
try:
|
||||
memberships = await tenant_service.get_user_memberships(user_id)
|
||||
logger.info("Retrieved user memberships", user_id=user_id, membership_count=len(memberships))
|
||||
return memberships
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Get user memberships failed", user_id=user_id, error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get user memberships"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TENANT MODEL STATUS OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}/model-status", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_update_model_status")
|
||||
async def update_tenant_model_status(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
ml_model_trained: bool = Query(..., description="Whether model is trained"),
|
||||
last_training_date: Optional[datetime] = Query(None, description="Last training date"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Update tenant model training status with enhanced tracking"""
|
||||
|
||||
try:
|
||||
result = await tenant_service.update_model_status(
|
||||
str(tenant_id),
|
||||
ml_model_trained,
|
||||
current_user["user_id"],
|
||||
last_training_date
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Model status update failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update model status"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TENANT ACTIVATION/DEACTIVATION OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/deactivate", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_deactivate")
|
||||
@owner_role_required
|
||||
async def deactivate_tenant(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Deactivate a tenant (owner only) with enhanced validation"""
|
||||
|
||||
try:
|
||||
success = await tenant_service.deactivate_tenant(
|
||||
str(tenant_id),
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
if success:
|
||||
try:
|
||||
from app.core.database import get_db_session
|
||||
async with get_db_session() as db:
|
||||
await audit_logger.log_event(
|
||||
db_session=db,
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user["user_id"],
|
||||
action=AuditAction.DEACTIVATE.value,
|
||||
resource_type="tenant",
|
||||
resource_id=str(tenant_id),
|
||||
severity=AuditSeverity.CRITICAL.value,
|
||||
description=f"Owner {current_user.get('email', current_user['user_id'])} deactivated tenant",
|
||||
endpoint="/{tenant_id}/deactivate",
|
||||
method="POST"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
return {"success": True, "message": "Tenant deactivated successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to deactivate tenant"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Tenant deactivation failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to deactivate tenant"
|
||||
)
|
||||
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/activate", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_activate")
|
||||
@owner_role_required
|
||||
async def activate_tenant(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Activate a previously deactivated tenant (owner only) with enhanced validation"""
|
||||
|
||||
try:
|
||||
success = await tenant_service.activate_tenant(
|
||||
str(tenant_id),
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
if success:
|
||||
try:
|
||||
from app.core.database import get_db_session
|
||||
async with get_db_session() as db:
|
||||
await audit_logger.log_event(
|
||||
db_session=db,
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user["user_id"],
|
||||
action=AuditAction.ACTIVATE.value,
|
||||
resource_type="tenant",
|
||||
resource_id=str(tenant_id),
|
||||
severity=AuditSeverity.HIGH.value,
|
||||
description=f"Owner {current_user.get('email', current_user['user_id'])} activated tenant",
|
||||
endpoint="/{tenant_id}/activate",
|
||||
method="POST"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
return {"success": True, "message": "Tenant activated successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to activate tenant"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Tenant activation failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to activate tenant"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TENANT STATISTICS & ANALYTICS
|
||||
# ============================================================================
|
||||
|
||||
@router.get(route_builder.build_base_route("statistics", include_tenant_prefix=False), dependencies=[Depends(require_admin_role_dep)])
|
||||
@track_endpoint_metrics("tenant_get_statistics")
|
||||
async def get_tenant_statistics(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get comprehensive tenant statistics (admin only) with enhanced analytics"""
|
||||
|
||||
try:
|
||||
stats = await tenant_service.get_tenant_statistics()
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Get tenant statistics failed", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get tenant statistics"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# USER-TENANT RELATIONSHIP OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
@router.get(route_builder.build_base_route("users/{user_id}/primary-tenant", include_tenant_prefix=False))
|
||||
async def get_user_primary_tenant(
|
||||
user_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Get the primary tenant for a user
|
||||
|
||||
This endpoint is used by the auth service to validate user subscriptions
|
||||
during login. It returns the user's primary tenant (the one they own or
|
||||
have primary access to).
|
||||
|
||||
Args:
|
||||
user_id: The user ID to look up
|
||||
|
||||
Returns:
|
||||
Dictionary with user's primary tenant information, or None if no tenant found
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"user_id": "user-uuid",
|
||||
"tenant_id": "tenant-uuid",
|
||||
"tenant_name": "Bakery Name",
|
||||
"tenant_type": "standalone",
|
||||
"is_owner": true
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
from app.models.tenants import Tenant
|
||||
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
|
||||
# Get user's primary tenant (the one they own)
|
||||
primary_tenant = await tenant_repo.get_user_primary_tenant(user_id)
|
||||
|
||||
if primary_tenant:
|
||||
logger.info("Found primary tenant for user",
|
||||
user_id=user_id,
|
||||
tenant_id=str(primary_tenant.id),
|
||||
tenant_name=primary_tenant.name)
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'tenant_id': str(primary_tenant.id),
|
||||
'tenant_name': primary_tenant.name,
|
||||
'tenant_type': primary_tenant.tenant_type,
|
||||
'is_owner': True
|
||||
}
|
||||
else:
|
||||
# If no primary tenant found, check if user has access to any tenant
|
||||
any_tenant = await tenant_repo.get_any_user_tenant(user_id)
|
||||
|
||||
if any_tenant:
|
||||
logger.info("Found accessible tenant for user",
|
||||
user_id=user_id,
|
||||
tenant_id=str(any_tenant.id),
|
||||
tenant_name=any_tenant.name)
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'tenant_id': str(any_tenant.id),
|
||||
'tenant_name': any_tenant.name,
|
||||
'tenant_type': any_tenant.tenant_type,
|
||||
'is_owner': False
|
||||
}
|
||||
else:
|
||||
logger.info("No tenant found for user", user_id=user_id)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get primary tenant for user {user_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get primary tenant: {str(e)}")
|
||||
186
services/tenant/app/api/tenant_settings.py
Normal file
186
services/tenant/app/api/tenant_settings.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# services/tenant/app/api/tenant_settings.py
|
||||
"""
|
||||
Tenant Settings API Endpoints
|
||||
REST API for managing tenant-specific operational settings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uuid import UUID
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.database import get_db
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from ..services.tenant_settings_service import TenantSettingsService
|
||||
from ..schemas.tenant_settings import (
|
||||
TenantSettingsResponse,
|
||||
TenantSettingsUpdate,
|
||||
CategoryUpdateRequest,
|
||||
CategoryResetResponse
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}/settings",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Get all tenant settings",
|
||||
description="Retrieve all operational settings for a tenant. Creates default settings if none exist."
|
||||
)
|
||||
async def get_tenant_settings(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all settings for a tenant
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
|
||||
Returns all setting categories with their current values.
|
||||
If settings don't exist, default values are created and returned.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.get_settings(tenant_id)
|
||||
return settings
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tenant_id}/settings",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Update tenant settings",
|
||||
description="Update one or more setting categories for a tenant. Only provided categories are updated."
|
||||
)
|
||||
async def update_tenant_settings(
|
||||
tenant_id: UUID,
|
||||
updates: TenantSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update tenant settings
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **updates**: Object containing setting categories to update
|
||||
|
||||
Only provided categories will be updated. Omitted categories remain unchanged.
|
||||
All values are validated against min/max constraints.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.update_settings(tenant_id, updates)
|
||||
return settings
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}/settings/{category}",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Get settings for a specific category",
|
||||
description="Retrieve settings for a single category (procurement, inventory, production, supplier, pos, or order)"
|
||||
)
|
||||
async def get_category_settings(
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get settings for a specific category
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **category**: Category name (procurement, inventory, production, supplier, pos, order)
|
||||
|
||||
Returns settings for the specified category only.
|
||||
|
||||
Valid categories:
|
||||
- procurement: Auto-approval and procurement planning settings
|
||||
- inventory: Stock thresholds and temperature monitoring
|
||||
- production: Capacity, quality, and scheduling settings
|
||||
- supplier: Payment terms and performance thresholds
|
||||
- pos: POS integration sync settings
|
||||
- order: Discount and delivery settings
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
category_settings = await service.get_category(tenant_id, category)
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"category": category,
|
||||
"settings": category_settings
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tenant_id}/settings/{category}",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Update settings for a specific category",
|
||||
description="Update all or some fields within a single category"
|
||||
)
|
||||
async def update_category_settings(
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
request: CategoryUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update settings for a specific category
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **category**: Category name
|
||||
- **request**: Object containing the settings to update
|
||||
|
||||
Updates only the specified category. All values are validated.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.update_category(tenant_id, category, request.settings)
|
||||
return settings
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tenant_id}/settings/{category}/reset",
|
||||
response_model=CategoryResetResponse,
|
||||
summary="Reset category to default values",
|
||||
description="Reset a specific category to its default values"
|
||||
)
|
||||
async def reset_category_settings(
|
||||
tenant_id: UUID,
|
||||
category: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Reset a category to default values
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **category**: Category name
|
||||
|
||||
Resets all settings in the specified category to their default values.
|
||||
This operation cannot be undone.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
reset_settings = await service.reset_category(tenant_id, category)
|
||||
|
||||
return CategoryResetResponse(
|
||||
category=category,
|
||||
settings=reset_settings,
|
||||
message=f"Category '{category}' has been reset to default values"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tenant_id}/settings",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete tenant settings",
|
||||
description="Delete all settings for a tenant (used when tenant is deleted)"
|
||||
)
|
||||
async def delete_tenant_settings(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete tenant settings
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
|
||||
This endpoint is typically called automatically when a tenant is deleted.
|
||||
It removes all setting data for the tenant.
|
||||
"""
|
||||
service = TenantSettingsService(db)
|
||||
await service.delete_settings(tenant_id)
|
||||
return None
|
||||
285
services/tenant/app/api/tenants.py
Normal file
285
services/tenant/app/api/tenants.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Tenant API - ATOMIC operations
|
||||
Handles basic CRUD operations for tenants
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from typing import Dict, Any, List
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.tenants import TenantResponse, TenantUpdate
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import admin_role_required
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return EnhancedTenantService(database_manager)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
@router.get(route_builder.build_base_route("", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
@track_endpoint_metrics("tenants_list")
|
||||
async def get_active_tenants(
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get all active tenants - Available to service accounts and admins"""
|
||||
|
||||
logger.info(
|
||||
"Get active tenants request received",
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
user_id=current_user.get("user_id"),
|
||||
user_type=current_user.get("type", "user"),
|
||||
is_service=current_user.get("type") == "service",
|
||||
role=current_user.get("role"),
|
||||
service_name=current_user.get("service", "none")
|
||||
)
|
||||
|
||||
# Allow service accounts to call this endpoint
|
||||
if current_user.get("type") != "service":
|
||||
# For non-service users, could add additional role checks here if needed
|
||||
logger.debug(
|
||||
"Non-service user requesting active tenants",
|
||||
user_id=current_user.get("user_id"),
|
||||
role=current_user.get("role")
|
||||
)
|
||||
|
||||
tenants = await tenant_service.get_active_tenants(skip=skip, limit=limit)
|
||||
|
||||
logger.debug(
|
||||
"Get active tenants successful",
|
||||
count=len(tenants),
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return tenants
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
@track_endpoint_metrics("tenant_get")
|
||||
async def get_tenant(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get tenant by ID - ATOMIC operation - ENHANCED with logging"""
|
||||
|
||||
logger.info(
|
||||
"Tenant GET request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
user_type=current_user.get("type", "user"),
|
||||
is_service=current_user.get("type") == "service",
|
||||
role=current_user.get("role"),
|
||||
service_name=current_user.get("service", "none")
|
||||
)
|
||||
|
||||
tenant = await tenant_service.get_tenant_by_id(str(tenant_id))
|
||||
if not tenant:
|
||||
logger.warning(
|
||||
"Tenant not found",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Tenant GET request successful",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
|
||||
@admin_role_required
|
||||
async def update_tenant(
|
||||
update_data: TenantUpdate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Update tenant information - ATOMIC operation (Admin+ only)"""
|
||||
|
||||
try:
|
||||
result = await tenant_service.update_tenant(
|
||||
str(tenant_id),
|
||||
update_data,
|
||||
current_user["user_id"]
|
||||
)
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Tenant update failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user["user_id"],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Tenant update failed"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("user/{user_id}/tenants", include_tenant_prefix=False), response_model=List[TenantResponse])
|
||||
@track_endpoint_metrics("user_tenants_list")
|
||||
async def get_user_tenants(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Get all tenants accessible by a user"""
|
||||
|
||||
logger.info(
|
||||
"Get user tenants request received",
|
||||
user_id=user_id,
|
||||
requesting_user=current_user.get("user_id"),
|
||||
is_demo=current_user.get("is_demo", False)
|
||||
)
|
||||
|
||||
# Allow demo users to access tenant information for demo-user
|
||||
is_demo_user = current_user.get("is_demo", False)
|
||||
is_service_account = current_user.get("type") == "service"
|
||||
|
||||
# For demo sessions, when frontend requests with "demo-user", use the actual demo owner ID
|
||||
actual_user_id = user_id
|
||||
if is_demo_user and user_id == "demo-user":
|
||||
actual_user_id = current_user.get("user_id")
|
||||
logger.info(
|
||||
"Demo session: mapping demo-user to actual owner",
|
||||
requested_user_id=user_id,
|
||||
actual_user_id=actual_user_id
|
||||
)
|
||||
|
||||
if current_user.get("user_id") != actual_user_id and not is_service_account and not (is_demo_user and user_id == "demo-user"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only access own tenants"
|
||||
)
|
||||
|
||||
try:
|
||||
# For demo sessions, use session-specific filtering to prevent cross-session data leakage
|
||||
if is_demo_user:
|
||||
demo_session_id = current_user.get("demo_session_id")
|
||||
demo_account_type = current_user.get("demo_account_type", "professional")
|
||||
|
||||
logger.info(
|
||||
"Demo session detected for get_user_tenants",
|
||||
user_id=user_id,
|
||||
actual_user_id=actual_user_id,
|
||||
demo_session_id=demo_session_id,
|
||||
demo_account_type=demo_account_type,
|
||||
has_session_id=bool(demo_session_id)
|
||||
)
|
||||
|
||||
if demo_session_id:
|
||||
# Get only tenants for this specific demo session
|
||||
tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type)
|
||||
logger.info(
|
||||
"Get demo session tenants successful",
|
||||
user_id=user_id,
|
||||
demo_session_id=demo_session_id,
|
||||
demo_account_type=demo_account_type,
|
||||
tenant_count=len(tenants),
|
||||
tenant_ids=[str(t.id) for t in tenants] if tenants else []
|
||||
)
|
||||
return tenants
|
||||
else:
|
||||
logger.warning(
|
||||
"Demo user without session ID - falling back to regular user tenants",
|
||||
user_id=actual_user_id
|
||||
)
|
||||
|
||||
# Regular users or demo fallback: get tenants by ownership and membership
|
||||
tenants = await tenant_service.get_user_tenants(actual_user_id)
|
||||
|
||||
logger.debug(
|
||||
"Get user tenants successful",
|
||||
user_id=user_id,
|
||||
tenant_count=len(tenants)
|
||||
)
|
||||
|
||||
return tenants
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get user tenants failed",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get user tenants"
|
||||
)
|
||||
|
||||
@router.delete(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False))
|
||||
@track_endpoint_metrics("tenant_delete")
|
||||
async def delete_tenant(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||
):
|
||||
"""Delete tenant and all associated data - ATOMIC operation (Owner/Admin or System only)"""
|
||||
|
||||
logger.info(
|
||||
"Tenant DELETE request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
user_type=current_user.get("type", "user"),
|
||||
is_service=current_user.get("type") == "service",
|
||||
role=current_user.get("role"),
|
||||
service_name=current_user.get("service", "none")
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow internal service calls to bypass admin check
|
||||
skip_admin_check = current_user.get("type") == "service"
|
||||
|
||||
result = await tenant_service.delete_tenant(
|
||||
str(tenant_id),
|
||||
requesting_user_id=current_user.get("user_id"),
|
||||
skip_admin_check=skip_admin_check
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Tenant DELETE request successful",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
deleted_items=result.get("deleted_items")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Tenant deleted successfully",
|
||||
"summary": result
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Tenant deletion failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Tenant deletion failed"
|
||||
)
|
||||
357
services/tenant/app/api/usage_forecast.py
Normal file
357
services/tenant/app/api/usage_forecast.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Usage Forecasting API
|
||||
|
||||
This endpoint predicts when a tenant will hit their subscription limits
|
||||
based on historical usage growth rates.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
import redis.asyncio as redis
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from app.core.config import settings
|
||||
from app.core.database import database_manager
|
||||
from app.services.subscription_limit_service import SubscriptionLimitService
|
||||
|
||||
router = APIRouter(prefix="/usage-forecast", tags=["usage-forecast"])
|
||||
|
||||
|
||||
class UsageDataPoint(BaseModel):
|
||||
"""Single usage data point"""
|
||||
date: str
|
||||
value: int
|
||||
|
||||
|
||||
class MetricForecast(BaseModel):
|
||||
"""Forecast for a single metric"""
|
||||
metric: str
|
||||
label: str
|
||||
current: int
|
||||
limit: Optional[int] # None = unlimited
|
||||
unit: str
|
||||
daily_growth_rate: Optional[float] # None if not enough data
|
||||
predicted_breach_date: Optional[str] # ISO date string, None if unlimited or no breach
|
||||
days_until_breach: Optional[int] # None if unlimited or no breach
|
||||
usage_percentage: float
|
||||
status: str # 'safe', 'warning', 'critical', 'unlimited'
|
||||
trend_data: List[UsageDataPoint] # 30-day history
|
||||
|
||||
|
||||
class UsageForecastResponse(BaseModel):
|
||||
"""Complete usage forecast response"""
|
||||
tenant_id: str
|
||||
forecasted_at: str
|
||||
metrics: List[MetricForecast]
|
||||
|
||||
|
||||
async def get_redis_client() -> redis.Redis:
|
||||
"""Get Redis client for usage tracking"""
|
||||
return redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
|
||||
async def get_usage_history(
|
||||
redis_client: redis.Redis,
|
||||
tenant_id: str,
|
||||
metric: str,
|
||||
days: int = 30
|
||||
) -> List[UsageDataPoint]:
|
||||
"""
|
||||
Get historical usage data for a metric from Redis
|
||||
|
||||
Usage data is stored with keys like:
|
||||
usage:daily:{tenant_id}:{metric}:{date}
|
||||
"""
|
||||
history = []
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
for i in range(days):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.isoformat()
|
||||
key = f"usage:daily:{tenant_id}:{metric}:{date_str}"
|
||||
|
||||
try:
|
||||
value = await redis_client.get(key)
|
||||
if value is not None:
|
||||
history.append(UsageDataPoint(
|
||||
date=date_str,
|
||||
value=int(value)
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Error fetching usage for {key}: {e}")
|
||||
continue
|
||||
|
||||
# Return in chronological order (oldest first)
|
||||
return list(reversed(history))
|
||||
|
||||
|
||||
def calculate_growth_rate(history: List[UsageDataPoint]) -> Optional[float]:
|
||||
"""
|
||||
Calculate daily growth rate using linear regression
|
||||
|
||||
Returns average daily increase, or None if insufficient data
|
||||
"""
|
||||
if len(history) < 7: # Need at least 7 days of data
|
||||
return None
|
||||
|
||||
# Simple linear regression
|
||||
n = len(history)
|
||||
sum_x = sum(range(n))
|
||||
sum_y = sum(point.value for point in history)
|
||||
sum_xy = sum(i * point.value for i, point in enumerate(history))
|
||||
sum_x_squared = sum(i * i for i in range(n))
|
||||
|
||||
# Calculate slope (daily growth rate)
|
||||
denominator = (n * sum_x_squared) - (sum_x ** 2)
|
||||
if denominator == 0:
|
||||
return None
|
||||
|
||||
slope = ((n * sum_xy) - (sum_x * sum_y)) / denominator
|
||||
|
||||
return max(slope, 0) # Can't have negative growth for breach prediction
|
||||
|
||||
|
||||
def predict_breach_date(
|
||||
current: int,
|
||||
limit: int,
|
||||
daily_growth_rate: float
|
||||
) -> Optional[tuple[str, int]]:
|
||||
"""
|
||||
Predict when usage will breach the limit
|
||||
|
||||
Returns (breach_date_iso, days_until_breach) or None if no breach predicted
|
||||
"""
|
||||
if daily_growth_rate <= 0:
|
||||
return None
|
||||
|
||||
remaining_capacity = limit - current
|
||||
if remaining_capacity <= 0:
|
||||
# Already at or over limit
|
||||
return datetime.utcnow().date().isoformat(), 0
|
||||
|
||||
days_until_breach = int(remaining_capacity / daily_growth_rate)
|
||||
|
||||
if days_until_breach > 365: # Don't predict beyond 1 year
|
||||
return None
|
||||
|
||||
breach_date = datetime.utcnow().date() + timedelta(days=days_until_breach)
|
||||
|
||||
return breach_date.isoformat(), days_until_breach
|
||||
|
||||
|
||||
def determine_status(usage_percentage: float, days_until_breach: Optional[int]) -> str:
|
||||
"""Determine metric status based on usage and time to breach"""
|
||||
if usage_percentage >= 100:
|
||||
return 'critical'
|
||||
elif usage_percentage >= 90:
|
||||
return 'critical'
|
||||
elif usage_percentage >= 80 or (days_until_breach is not None and days_until_breach <= 14):
|
||||
return 'warning'
|
||||
else:
|
||||
return 'safe'
|
||||
|
||||
|
||||
@router.get("", response_model=UsageForecastResponse)
|
||||
async def get_usage_forecast(
|
||||
tenant_id: str = Query(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
) -> UsageForecastResponse:
|
||||
"""
|
||||
Get usage forecasts for all metrics
|
||||
|
||||
Predicts when the tenant will hit their subscription limits based on
|
||||
historical usage growth rates from the past 30 days.
|
||||
|
||||
Returns predictions for:
|
||||
- Users
|
||||
- Locations
|
||||
- Products
|
||||
- Recipes
|
||||
- Suppliers
|
||||
- Training jobs (daily)
|
||||
- Forecasts (daily)
|
||||
- API calls (hourly average converted to daily)
|
||||
- File storage
|
||||
"""
|
||||
# Initialize services
|
||||
redis_client = await get_redis_client()
|
||||
limit_service = SubscriptionLimitService(database_manager=database_manager)
|
||||
|
||||
try:
|
||||
# Get current usage summary (includes limits)
|
||||
usage_summary = await limit_service.get_usage_summary(tenant_id)
|
||||
|
||||
if not usage_summary or 'error' in usage_summary:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active subscription found for tenant {tenant_id}"
|
||||
)
|
||||
|
||||
# Extract usage data
|
||||
usage = usage_summary.get('usage', {})
|
||||
|
||||
# Define metrics to forecast
|
||||
metric_configs = [
|
||||
{
|
||||
'key': 'users',
|
||||
'label': 'Users',
|
||||
'current': usage.get('users', {}).get('current', 0),
|
||||
'limit': usage.get('users', {}).get('limit'),
|
||||
'unit': ''
|
||||
},
|
||||
{
|
||||
'key': 'locations',
|
||||
'label': 'Locations',
|
||||
'current': usage.get('locations', {}).get('current', 0),
|
||||
'limit': usage.get('locations', {}).get('limit'),
|
||||
'unit': ''
|
||||
},
|
||||
{
|
||||
'key': 'products',
|
||||
'label': 'Products',
|
||||
'current': usage.get('products', {}).get('current', 0),
|
||||
'limit': usage.get('products', {}).get('limit'),
|
||||
'unit': ''
|
||||
},
|
||||
{
|
||||
'key': 'recipes',
|
||||
'label': 'Recipes',
|
||||
'current': usage.get('recipes', {}).get('current', 0),
|
||||
'limit': usage.get('recipes', {}).get('limit'),
|
||||
'unit': ''
|
||||
},
|
||||
{
|
||||
'key': 'suppliers',
|
||||
'label': 'Suppliers',
|
||||
'current': usage.get('suppliers', {}).get('current', 0),
|
||||
'limit': usage.get('suppliers', {}).get('limit'),
|
||||
'unit': ''
|
||||
},
|
||||
{
|
||||
'key': 'training_jobs',
|
||||
'label': 'Training Jobs',
|
||||
'current': usage.get('training_jobs_today', {}).get('current', 0),
|
||||
'limit': usage.get('training_jobs_today', {}).get('limit'),
|
||||
'unit': '/day'
|
||||
},
|
||||
{
|
||||
'key': 'forecasts',
|
||||
'label': 'Forecasts',
|
||||
'current': usage.get('forecasts_today', {}).get('current', 0),
|
||||
'limit': usage.get('forecasts_today', {}).get('limit'),
|
||||
'unit': '/day'
|
||||
},
|
||||
{
|
||||
'key': 'api_calls',
|
||||
'label': 'API Calls',
|
||||
'current': usage.get('api_calls_this_hour', {}).get('current', 0),
|
||||
'limit': usage.get('api_calls_this_hour', {}).get('limit'),
|
||||
'unit': '/hour'
|
||||
},
|
||||
{
|
||||
'key': 'storage',
|
||||
'label': 'File Storage',
|
||||
'current': int(usage.get('file_storage_used_gb', {}).get('current', 0)),
|
||||
'limit': usage.get('file_storage_used_gb', {}).get('limit'),
|
||||
'unit': ' GB'
|
||||
}
|
||||
]
|
||||
|
||||
forecasts: List[MetricForecast] = []
|
||||
|
||||
for config in metric_configs:
|
||||
metric_key = config['key']
|
||||
current = config['current']
|
||||
limit = config['limit']
|
||||
|
||||
# Get usage history
|
||||
history = await get_usage_history(redis_client, tenant_id, metric_key, days=30)
|
||||
|
||||
# Calculate usage percentage
|
||||
if limit is None or limit == -1:
|
||||
usage_percentage = 0.0
|
||||
status = 'unlimited'
|
||||
growth_rate = None
|
||||
breach_date = None
|
||||
days_until = None
|
||||
else:
|
||||
usage_percentage = (current / limit * 100) if limit > 0 else 0
|
||||
|
||||
# Calculate growth rate
|
||||
growth_rate = calculate_growth_rate(history) if history else None
|
||||
|
||||
# Predict breach
|
||||
if growth_rate is not None and growth_rate > 0:
|
||||
breach_result = predict_breach_date(current, limit, growth_rate)
|
||||
if breach_result:
|
||||
breach_date, days_until = breach_result
|
||||
else:
|
||||
breach_date, days_until = None, None
|
||||
else:
|
||||
breach_date, days_until = None, None
|
||||
|
||||
# Determine status
|
||||
status = determine_status(usage_percentage, days_until)
|
||||
|
||||
forecasts.append(MetricForecast(
|
||||
metric=metric_key,
|
||||
label=config['label'],
|
||||
current=current,
|
||||
limit=limit,
|
||||
unit=config['unit'],
|
||||
daily_growth_rate=growth_rate,
|
||||
predicted_breach_date=breach_date,
|
||||
days_until_breach=days_until,
|
||||
usage_percentage=round(usage_percentage, 1),
|
||||
status=status,
|
||||
trend_data=history[-30:] # Last 30 days
|
||||
))
|
||||
|
||||
return UsageForecastResponse(
|
||||
tenant_id=tenant_id,
|
||||
forecasted_at=datetime.utcnow().isoformat(),
|
||||
metrics=forecasts
|
||||
)
|
||||
|
||||
finally:
|
||||
await redis_client.close()
|
||||
|
||||
|
||||
@router.post("/track-usage")
|
||||
async def track_daily_usage(
|
||||
tenant_id: str,
|
||||
metric: str,
|
||||
value: int,
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Manually track daily usage for a metric
|
||||
|
||||
This endpoint is called by services to record daily usage snapshots.
|
||||
The data is stored in Redis with a 60-day TTL.
|
||||
"""
|
||||
redis_client = await get_redis_client()
|
||||
|
||||
try:
|
||||
date_str = datetime.utcnow().date().isoformat()
|
||||
key = f"usage:daily:{tenant_id}:{metric}:{date_str}"
|
||||
|
||||
# Store usage with 60-day TTL
|
||||
await redis_client.setex(key, 60 * 24 * 60 * 60, str(value))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tenant_id": tenant_id,
|
||||
"metric": metric,
|
||||
"value": value,
|
||||
"date": date_str
|
||||
}
|
||||
|
||||
finally:
|
||||
await redis_client.close()
|
||||
97
services/tenant/app/api/webhooks.py
Normal file
97
services/tenant/app/api/webhooks.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Webhook endpoints for handling payment provider events
|
||||
These endpoints receive events from payment providers like Stripe
|
||||
All event processing is handled by SubscriptionOrchestrationService
|
||||
"""
|
||||
|
||||
import structlog
|
||||
import stripe
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.subscription_orchestration_service import SubscriptionOrchestrationService
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_subscription_orchestration_service(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> SubscriptionOrchestrationService:
|
||||
"""Dependency injection for SubscriptionOrchestrationService"""
|
||||
try:
|
||||
return SubscriptionOrchestrationService(db)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create subscription orchestration service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
@router.post("/webhooks/stripe")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
orchestration_service: SubscriptionOrchestrationService = Depends(get_subscription_orchestration_service)
|
||||
):
|
||||
"""
|
||||
Stripe webhook endpoint to handle payment events
|
||||
This endpoint verifies webhook signatures and processes Stripe events
|
||||
"""
|
||||
try:
|
||||
# Get the payload and signature
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get('stripe-signature')
|
||||
|
||||
if not sig_header:
|
||||
logger.error("Missing stripe-signature header")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing signature header"
|
||||
)
|
||||
|
||||
# Verify the webhook signature
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
logger.error("Invalid webhook signature", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid signature"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Invalid payload", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid payload"
|
||||
)
|
||||
|
||||
# Get event type and data
|
||||
event_type = event['type']
|
||||
event_data = event['data']['object']
|
||||
|
||||
logger.info("Processing Stripe webhook event",
|
||||
event_type=event_type,
|
||||
event_id=event.get('id'))
|
||||
|
||||
# Use orchestration service to handle the event
|
||||
result = await orchestration_service.handle_payment_webhook(event_type, event_data)
|
||||
|
||||
logger.info("Webhook event processed via orchestration service",
|
||||
event_type=event_type,
|
||||
actions_taken=result.get("actions_taken", []))
|
||||
|
||||
return {"success": True, "event_type": event_type, "actions_taken": result.get("actions_taken", [])}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error processing Stripe webhook", error=str(e), exc_info=True)
|
||||
# Return 200 OK even on processing errors to prevent Stripe retries
|
||||
# Only return 4xx for signature verification failures
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Webhook processing error",
|
||||
"details": str(e)
|
||||
}
|
||||
308
services/tenant/app/api/whatsapp_admin.py
Normal file
308
services/tenant/app/api/whatsapp_admin.py
Normal file
@@ -0,0 +1,308 @@
|
||||
# services/tenant/app/api/whatsapp_admin.py
|
||||
"""
|
||||
WhatsApp Admin API Endpoints
|
||||
Admin-only endpoints for managing WhatsApp phone number assignments
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import httpx
|
||||
import os
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tenant_settings import TenantSettings
|
||||
from app.models.tenants import Tenant
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# SCHEMAS
|
||||
# ================================================================
|
||||
|
||||
class WhatsAppPhoneNumberInfo(BaseModel):
|
||||
"""Information about a WhatsApp phone number from Meta API"""
|
||||
id: str = Field(..., description="Phone Number ID")
|
||||
display_phone_number: str = Field(..., description="Display phone number (e.g., +34 612 345 678)")
|
||||
verified_name: str = Field(..., description="Verified business name")
|
||||
quality_rating: str = Field(..., description="Quality rating (GREEN, YELLOW, RED)")
|
||||
|
||||
|
||||
class TenantWhatsAppStatus(BaseModel):
|
||||
"""WhatsApp status for a tenant"""
|
||||
tenant_id: UUID
|
||||
tenant_name: str
|
||||
whatsapp_enabled: bool
|
||||
phone_number_id: Optional[str] = None
|
||||
display_phone_number: Optional[str] = None
|
||||
|
||||
|
||||
class AssignPhoneNumberRequest(BaseModel):
|
||||
"""Request to assign phone number to tenant"""
|
||||
phone_number_id: str = Field(..., description="Meta WhatsApp Phone Number ID")
|
||||
display_phone_number: str = Field(..., description="Display format (e.g., '+34 612 345 678')")
|
||||
|
||||
|
||||
class AssignPhoneNumberResponse(BaseModel):
|
||||
"""Response after assigning phone number"""
|
||||
success: bool
|
||||
message: str
|
||||
tenant_id: UUID
|
||||
phone_number_id: str
|
||||
display_phone_number: str
|
||||
|
||||
|
||||
# ================================================================
|
||||
# ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
@router.get(
|
||||
"/admin/whatsapp/phone-numbers",
|
||||
response_model=List[WhatsAppPhoneNumberInfo],
|
||||
summary="List available WhatsApp phone numbers",
|
||||
description="Get all phone numbers available in the master WhatsApp Business Account"
|
||||
)
|
||||
async def list_available_phone_numbers():
|
||||
"""
|
||||
List all phone numbers from the master WhatsApp Business Account
|
||||
|
||||
Requires:
|
||||
- WHATSAPP_BUSINESS_ACCOUNT_ID environment variable
|
||||
- WHATSAPP_ACCESS_TOKEN environment variable
|
||||
|
||||
Returns list of available phone numbers with their status
|
||||
"""
|
||||
business_account_id = os.getenv("WHATSAPP_BUSINESS_ACCOUNT_ID")
|
||||
access_token = os.getenv("WHATSAPP_ACCESS_TOKEN")
|
||||
api_version = os.getenv("WHATSAPP_API_VERSION", "v18.0")
|
||||
|
||||
if not business_account_id or not access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="WhatsApp master account not configured. Set WHATSAPP_BUSINESS_ACCOUNT_ID and WHATSAPP_ACCESS_TOKEN environment variables."
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch phone numbers from Meta Graph API
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"https://graph.facebook.com/{api_version}/{business_account_id}/phone_numbers",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
params={
|
||||
"fields": "id,display_phone_number,verified_name,quality_rating"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_data = response.json()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Meta API error: {error_data.get('error', {}).get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
phone_numbers = data.get("data", [])
|
||||
|
||||
return [
|
||||
WhatsAppPhoneNumberInfo(
|
||||
id=phone.get("id"),
|
||||
display_phone_number=phone.get("display_phone_number"),
|
||||
verified_name=phone.get("verified_name", ""),
|
||||
quality_rating=phone.get("quality_rating", "UNKNOWN")
|
||||
)
|
||||
for phone in phone_numbers
|
||||
]
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Failed to fetch phone numbers from Meta: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/whatsapp/tenants",
|
||||
response_model=List[TenantWhatsAppStatus],
|
||||
summary="List all tenants with WhatsApp status",
|
||||
description="Get WhatsApp configuration status for all tenants"
|
||||
)
|
||||
async def list_tenant_whatsapp_status(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all tenants with their WhatsApp configuration status
|
||||
|
||||
Returns:
|
||||
- tenant_id: Tenant UUID
|
||||
- tenant_name: Tenant name
|
||||
- whatsapp_enabled: Whether WhatsApp is enabled
|
||||
- phone_number_id: Assigned phone number ID (if any)
|
||||
- display_phone_number: Display format (if any)
|
||||
"""
|
||||
# Query all tenants with their settings
|
||||
query = select(Tenant, TenantSettings).outerjoin(
|
||||
TenantSettings,
|
||||
Tenant.id == TenantSettings.tenant_id
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
tenant_statuses = []
|
||||
for tenant, settings in rows:
|
||||
notification_settings = settings.notification_settings if settings else {}
|
||||
|
||||
tenant_statuses.append(
|
||||
TenantWhatsAppStatus(
|
||||
tenant_id=tenant.id,
|
||||
tenant_name=tenant.name,
|
||||
whatsapp_enabled=notification_settings.get("whatsapp_enabled", False),
|
||||
phone_number_id=notification_settings.get("whatsapp_phone_number_id", ""),
|
||||
display_phone_number=notification_settings.get("whatsapp_display_phone_number", "")
|
||||
)
|
||||
)
|
||||
|
||||
return tenant_statuses
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/whatsapp/tenants/{tenant_id}/assign-phone",
|
||||
response_model=AssignPhoneNumberResponse,
|
||||
summary="Assign phone number to tenant",
|
||||
description="Assign a WhatsApp phone number from the master account to a tenant"
|
||||
)
|
||||
async def assign_phone_number_to_tenant(
|
||||
tenant_id: UUID,
|
||||
request: AssignPhoneNumberRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Assign a WhatsApp phone number to a tenant
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
- **phone_number_id**: Meta Phone Number ID from master account
|
||||
- **display_phone_number**: Human-readable format (e.g., "+34 612 345 678")
|
||||
|
||||
This will:
|
||||
1. Validate the tenant exists
|
||||
2. Check if phone number is already assigned to another tenant
|
||||
3. Update tenant's notification settings
|
||||
4. Enable WhatsApp for the tenant
|
||||
"""
|
||||
# Verify tenant exists
|
||||
tenant_query = select(Tenant).where(Tenant.id == tenant_id)
|
||||
tenant_result = await db.execute(tenant_query)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found"
|
||||
)
|
||||
|
||||
# Check if phone number is already assigned to another tenant
|
||||
settings_query = select(TenantSettings).where(TenantSettings.tenant_id != tenant_id)
|
||||
settings_result = await db.execute(settings_query)
|
||||
all_settings = settings_result.scalars().all()
|
||||
|
||||
for settings in all_settings:
|
||||
notification_settings = settings.notification_settings or {}
|
||||
if notification_settings.get("whatsapp_phone_number_id") == request.phone_number_id:
|
||||
# Get the other tenant's name
|
||||
other_tenant_query = select(Tenant).where(Tenant.id == settings.tenant_id)
|
||||
other_tenant_result = await db.execute(other_tenant_query)
|
||||
other_tenant = other_tenant_result.scalar_one_or_none()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Phone number {request.display_phone_number} is already assigned to tenant '{other_tenant.name if other_tenant else 'Unknown'}'"
|
||||
)
|
||||
|
||||
# Get or create tenant settings
|
||||
settings_query = select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||||
settings_result = await db.execute(settings_query)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
# Create default settings
|
||||
settings = TenantSettings(
|
||||
tenant_id=tenant_id,
|
||||
**TenantSettings.get_default_settings()
|
||||
)
|
||||
db.add(settings)
|
||||
|
||||
# Update notification settings
|
||||
notification_settings = settings.notification_settings or {}
|
||||
notification_settings["whatsapp_enabled"] = True
|
||||
notification_settings["whatsapp_phone_number_id"] = request.phone_number_id
|
||||
notification_settings["whatsapp_display_phone_number"] = request.display_phone_number
|
||||
|
||||
settings.notification_settings = notification_settings
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return AssignPhoneNumberResponse(
|
||||
success=True,
|
||||
message=f"Phone number {request.display_phone_number} assigned to tenant '{tenant.name}'",
|
||||
tenant_id=tenant_id,
|
||||
phone_number_id=request.phone_number_id,
|
||||
display_phone_number=request.display_phone_number
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/admin/whatsapp/tenants/{tenant_id}/unassign-phone",
|
||||
response_model=AssignPhoneNumberResponse,
|
||||
summary="Unassign phone number from tenant",
|
||||
description="Remove WhatsApp phone number assignment from a tenant"
|
||||
)
|
||||
async def unassign_phone_number_from_tenant(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Unassign WhatsApp phone number from a tenant
|
||||
|
||||
- **tenant_id**: UUID of the tenant
|
||||
|
||||
This will:
|
||||
1. Clear the phone number assignment
|
||||
2. Disable WhatsApp for the tenant
|
||||
"""
|
||||
# Get tenant settings
|
||||
settings_query = select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||||
settings_result = await db.execute(settings_query)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Settings not found for tenant {tenant_id}"
|
||||
)
|
||||
|
||||
# Get current values for response
|
||||
notification_settings = settings.notification_settings or {}
|
||||
old_phone_id = notification_settings.get("whatsapp_phone_number_id", "")
|
||||
old_display_phone = notification_settings.get("whatsapp_display_phone_number", "")
|
||||
|
||||
# Update notification settings
|
||||
notification_settings["whatsapp_enabled"] = False
|
||||
notification_settings["whatsapp_phone_number_id"] = ""
|
||||
notification_settings["whatsapp_display_phone_number"] = ""
|
||||
|
||||
settings.notification_settings = notification_settings
|
||||
|
||||
await db.commit()
|
||||
|
||||
return AssignPhoneNumberResponse(
|
||||
success=True,
|
||||
message=f"Phone number unassigned from tenant",
|
||||
tenant_id=tenant_id,
|
||||
phone_number_id=old_phone_id,
|
||||
display_phone_number=old_display_phone
|
||||
)
|
||||
Reference in New Issue
Block a user