Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
"""
Tenant API Package
API endpoints for tenant management
"""
from . import tenants
__all__ = ["tenants"]

View 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)}")

View 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"
}

View 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

View 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)}"
)

View 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"
)

File diff suppressed because it is too large Load Diff

View 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"]
)

View 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"
)

View 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"
)

View 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)}")

View 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

View 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"
)

View 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()

View 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)
}

View 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
)