New enterprise feature
This commit is contained in:
359
services/tenant/app/api/enterprise_upgrade.py
Normal file
359
services/tenant/app/api/enterprise_upgrade.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Enterprise Upgrade API
|
||||
Endpoints for upgrading tenants to enterprise tier and managing child outlets
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any, Optional
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tenants import Tenant
|
||||
from app.models.tenant_location import TenantLocation
|
||||
from app.services.tenant_service import EnhancedTenantService
|
||||
from app.core.config import settings
|
||||
from shared.auth.tenant_access import verify_tenant_permission_dep
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.clients.subscription_client import SubscriptionServiceClient, get_subscription_service_client
|
||||
from shared.subscription.plans import SubscriptionTier, QuotaLimits
|
||||
from shared.database.base import create_database_manager
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Dependency injection for enhanced tenant service
|
||||
def get_enhanced_tenant_service():
|
||||
try:
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
|
||||
return EnhancedTenantService(database_manager)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create enhanced tenant service", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
# Pydantic models for request bodies
|
||||
class EnterpriseUpgradeRequest(BaseModel):
|
||||
location_name: Optional[str] = Field(default="Central Production Facility")
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
production_capacity_kg: Optional[int] = Field(default=1000)
|
||||
|
||||
|
||||
class ChildOutletRequest(BaseModel):
|
||||
name: str
|
||||
subdomain: str
|
||||
address: str
|
||||
city: Optional[str] = None
|
||||
postal_code: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
delivery_days: Optional[list] = None
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/upgrade-to-enterprise")
|
||||
async def upgrade_to_enterprise(
|
||||
tenant_id: str,
|
||||
upgrade_data: EnterpriseUpgradeRequest,
|
||||
subscription_client: SubscriptionServiceClient = Depends(get_subscription_service_client),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Upgrade a tenant to enterprise tier with central production facility
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
# Get the current tenant
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
tenant = await tenant_repo.get_by_id(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
# Verify current subscription allows upgrade to enterprise
|
||||
current_subscription = await subscription_client.get_subscription(tenant_id)
|
||||
if current_subscription['plan'] not in [SubscriptionTier.STARTER.value, SubscriptionTier.PROFESSIONAL.value]:
|
||||
raise HTTPException(status_code=400, detail="Only starter and professional tier tenants can be upgraded to enterprise")
|
||||
|
||||
# Verify user has admin/owner role
|
||||
# This is handled by current_user check
|
||||
|
||||
# Update tenant to parent type
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
updated_tenant = await tenant_repo.update(
|
||||
tenant_id,
|
||||
{
|
||||
'tenant_type': 'parent',
|
||||
'hierarchy_path': f"{tenant_id}" # Root path
|
||||
}
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Create central production location
|
||||
location_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'name': upgrade_data.location_name,
|
||||
'location_type': 'central_production',
|
||||
'address': upgrade_data.address or tenant.address,
|
||||
'city': upgrade_data.city or tenant.city,
|
||||
'postal_code': upgrade_data.postal_code or tenant.postal_code,
|
||||
'latitude': upgrade_data.latitude or tenant.latitude,
|
||||
'longitude': upgrade_data.longitude or tenant.longitude,
|
||||
'capacity': upgrade_data.production_capacity_kg,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
from app.repositories.tenant_location_repository import TenantLocationRepository
|
||||
from app.core.database import database_manager
|
||||
|
||||
# Create async session
|
||||
async with database_manager.get_session() as session:
|
||||
location_repo = TenantLocationRepository(session)
|
||||
created_location = await location_repo.create_location(location_data)
|
||||
await session.commit()
|
||||
|
||||
# Update subscription to enterprise tier
|
||||
await subscription_client.update_subscription_plan(
|
||||
tenant_id=tenant_id,
|
||||
new_plan=SubscriptionTier.ENTERPRISE.value
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'tenant': updated_tenant,
|
||||
'production_location': created_location,
|
||||
'message': 'Tenant successfully upgraded to enterprise tier'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upgrade tenant: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{parent_id}/add-child-outlet")
|
||||
async def add_child_outlet(
|
||||
parent_id: str,
|
||||
child_data: ChildOutletRequest,
|
||||
subscription_client: SubscriptionServiceClient = Depends(get_subscription_service_client),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Add a new child outlet to a parent tenant
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
# Get parent tenant and verify it's a parent
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
parent_tenant = await tenant_repo.get_by_id(parent_id)
|
||||
if not parent_tenant:
|
||||
raise HTTPException(status_code=400, detail="Parent tenant not found")
|
||||
|
||||
parent_dict = {
|
||||
'id': str(parent_tenant.id),
|
||||
'name': parent_tenant.name,
|
||||
'tenant_type': parent_tenant.tenant_type,
|
||||
'subscription_tier': parent_tenant.subscription_tier,
|
||||
'business_type': parent_tenant.business_type,
|
||||
'business_model': parent_tenant.business_model,
|
||||
'city': parent_tenant.city,
|
||||
'phone': parent_tenant.phone,
|
||||
'email': parent_tenant.email,
|
||||
'owner_id': parent_tenant.owner_id
|
||||
}
|
||||
|
||||
if parent_dict.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=400, detail="Tenant is not a parent type")
|
||||
|
||||
# Validate subscription tier
|
||||
from shared.clients import get_tenant_client
|
||||
from shared.subscription.plans import PlanFeatures
|
||||
|
||||
tenant_client = get_tenant_client(config=settings, service_name="tenant-service")
|
||||
subscription = await tenant_client.get_tenant_subscription(parent_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="No active subscription found for parent tenant"
|
||||
)
|
||||
|
||||
tier = subscription.get("plan", "starter")
|
||||
if not PlanFeatures.validate_tenant_access(tier, "child"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Creating child outlets requires Enterprise subscription. Current plan: {tier}"
|
||||
)
|
||||
|
||||
# Check if parent has reached child quota
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
current_child_count = await tenant_repo.get_child_tenant_count(parent_id)
|
||||
|
||||
# Get max children from subscription plan
|
||||
max_children = QuotaLimits.get_limit("MAX_CHILD_TENANTS", tier)
|
||||
|
||||
if max_children is not None and current_child_count >= max_children:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Child tenant limit reached. Current: {current_child_count}, Maximum: {max_children}"
|
||||
)
|
||||
|
||||
# Create new child tenant
|
||||
child_id = str(uuid.uuid4())
|
||||
child_tenant_data = {
|
||||
'id': child_id,
|
||||
'name': child_data.name,
|
||||
'subdomain': child_data.subdomain,
|
||||
'business_type': parent_dict.get('business_type', 'bakery'),
|
||||
'business_model': parent_dict.get('business_model', 'retail_bakery'),
|
||||
'address': child_data.address,
|
||||
'city': child_data.city or parent_dict.get('city'),
|
||||
'postal_code': child_data.postal_code,
|
||||
'latitude': child_data.latitude,
|
||||
'longitude': child_data.longitude,
|
||||
'phone': child_data.phone or parent_dict.get('phone'),
|
||||
'email': child_data.email or parent_dict.get('email'),
|
||||
'parent_tenant_id': parent_id,
|
||||
'tenant_type': 'child',
|
||||
'hierarchy_path': f"{parent_id}.{child_id}",
|
||||
'owner_id': parent_dict.get('owner_id'), # Same owner as parent
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
# Use database managed session
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
created_child = await tenant_repo.create(child_tenant_data)
|
||||
await session.commit()
|
||||
|
||||
created_child_dict = {
|
||||
'id': str(created_child.id),
|
||||
'name': created_child.name,
|
||||
'subdomain': created_child.subdomain
|
||||
}
|
||||
|
||||
# Create retail outlet location for the child
|
||||
location_data = {
|
||||
'tenant_id': uuid.UUID(child_id),
|
||||
'name': f"Outlet - {child_data.name}",
|
||||
'location_type': 'retail_outlet',
|
||||
'address': child_data.address,
|
||||
'city': child_data.city or parent_dict.get('city'),
|
||||
'postal_code': child_data.postal_code,
|
||||
'latitude': child_data.latitude,
|
||||
'longitude': child_data.longitude,
|
||||
'delivery_windows': child_data.delivery_days,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
from app.repositories.tenant_location_repository import TenantLocationRepository
|
||||
|
||||
# Create async session
|
||||
async with database_manager.get_session() as session:
|
||||
location_repo = TenantLocationRepository(session)
|
||||
created_location = await location_repo.create_location(location_data)
|
||||
await session.commit()
|
||||
|
||||
location_dict = {
|
||||
'id': str(created_location.id) if created_location else None,
|
||||
'name': created_location.name if created_location else None
|
||||
}
|
||||
|
||||
# Copy relevant settings from parent (with child-specific overrides)
|
||||
# This would typically involve copying settings via tenant settings service
|
||||
|
||||
# Create child subscription inheriting from parent
|
||||
await subscription_client.create_child_subscription(
|
||||
child_tenant_id=child_id,
|
||||
parent_tenant_id=parent_id
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'child_tenant': created_child_dict,
|
||||
'location': location_dict,
|
||||
'message': 'Child outlet successfully added'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add child outlet: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/hierarchy")
|
||||
async def get_tenant_hierarchy(
|
||||
tenant_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Get tenant hierarchy information
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
tenant = await tenant_repo.get_by_id(tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
result = {
|
||||
'tenant_id': tenant_id,
|
||||
'name': tenant.name,
|
||||
'tenant_type': tenant.tenant_type,
|
||||
'parent_tenant_id': tenant.parent_tenant_id,
|
||||
'hierarchy_path': tenant.hierarchy_path,
|
||||
'is_parent': tenant.tenant_type == 'parent',
|
||||
'is_child': tenant.tenant_type == 'child'
|
||||
}
|
||||
|
||||
# If this is a parent, include child count
|
||||
if tenant.tenant_type == 'parent':
|
||||
child_count = await tenant_repo.get_child_tenant_count(tenant_id)
|
||||
result['child_tenant_count'] = child_count
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get hierarchy: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/tenant-hierarchy")
|
||||
async def get_user_accessible_tenant_hierarchy(
|
||||
user_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Get all tenants a user has access to, organized in hierarchy
|
||||
"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
|
||||
# Fetch all tenants where user has access, organized hierarchically
|
||||
async with database_manager.get_session() as session:
|
||||
tenant_repo = TenantRepository(Tenant, session)
|
||||
user_tenants = await tenant_repo.get_user_tenants_with_hierarchy(user_id)
|
||||
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'tenants': user_tenants,
|
||||
'total_count': len(user_tenants)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get user hierarchy: {str(e)}")
|
||||
628
services/tenant/app/api/tenant_locations.py
Normal file
628
services/tenant/app/api/tenant_locations.py
Normal file
@@ -0,0 +1,628 @@
|
||||
"""
|
||||
Tenant Locations API - Handles tenant location operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.tenant_locations import (
|
||||
TenantLocationCreate,
|
||||
TenantLocationUpdate,
|
||||
TenantLocationResponse,
|
||||
TenantLocationsResponse,
|
||||
TenantLocationTypeFilter
|
||||
)
|
||||
from app.repositories.tenant_location_repository import TenantLocationRepository
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import admin_role_required
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder("tenants")
|
||||
|
||||
|
||||
# Dependency injection for tenant location repository
|
||||
async def get_tenant_location_repository():
|
||||
"""Get tenant location repository instance with proper session management"""
|
||||
try:
|
||||
from app.core.database import database_manager
|
||||
|
||||
# Use async context manager properly to ensure session is closed
|
||||
async with database_manager.get_session() as session:
|
||||
yield TenantLocationRepository(session)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create tenant location repository", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Service initialization failed")
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/locations", include_tenant_prefix=False), response_model=TenantLocationsResponse)
|
||||
@track_endpoint_metrics("tenant_locations_list")
|
||||
async def get_tenant_locations(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_types: str = Query(None, description="Comma-separated list of location types to filter"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Get all locations for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant to get locations for
|
||||
location_types: Optional comma-separated list of location types to filter (e.g., "central_production,retail_outlet")
|
||||
is_active: Optional filter for active locations only
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant locations request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
location_types=location_types,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
# Check that the user has access to this tenant
|
||||
# This would typically be checked via access control middleware
|
||||
# For now, we'll trust the gateway has validated tenant access
|
||||
|
||||
locations = []
|
||||
|
||||
if location_types:
|
||||
# Filter by specific location types
|
||||
types_list = [t.strip() for t in location_types.split(",")]
|
||||
locations = await location_repo.get_locations_by_tenant_with_type(str(tenant_id), types_list)
|
||||
elif is_active is True:
|
||||
# Get only active locations
|
||||
locations = await location_repo.get_active_locations_by_tenant(str(tenant_id))
|
||||
elif is_active is False:
|
||||
# Get only inactive locations (by getting all and filtering in memory - not efficient but functional)
|
||||
all_locations = await location_repo.get_locations_by_tenant(str(tenant_id))
|
||||
locations = [loc for loc in all_locations if not loc.is_active]
|
||||
else:
|
||||
# Get all locations
|
||||
locations = await location_repo.get_locations_by_tenant(str(tenant_id))
|
||||
|
||||
logger.debug(
|
||||
"Get tenant locations successful",
|
||||
tenant_id=str(tenant_id),
|
||||
location_count=len(locations)
|
||||
)
|
||||
|
||||
# Convert to response format - handle metadata field to avoid SQLAlchemy conflicts
|
||||
location_responses = []
|
||||
for loc in locations:
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(loc.id),
|
||||
'tenant_id': str(loc.tenant_id),
|
||||
'name': loc.name,
|
||||
'location_type': loc.location_type,
|
||||
'address': loc.address,
|
||||
'city': loc.city,
|
||||
'postal_code': loc.postal_code,
|
||||
'latitude': loc.latitude,
|
||||
'longitude': loc.longitude,
|
||||
'contact_person': loc.contact_person,
|
||||
'contact_phone': loc.contact_phone,
|
||||
'contact_email': loc.contact_email,
|
||||
'is_active': loc.is_active,
|
||||
'delivery_windows': loc.delivery_windows,
|
||||
'operational_hours': loc.operational_hours,
|
||||
'capacity': loc.capacity,
|
||||
'max_delivery_radius_km': loc.max_delivery_radius_km,
|
||||
'delivery_schedule_config': loc.delivery_schedule_config,
|
||||
'metadata': loc.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': loc.created_at,
|
||||
'updated_at': loc.updated_at
|
||||
}
|
||||
location_responses.append(TenantLocationResponse.model_validate(loc_dict))
|
||||
|
||||
return TenantLocationsResponse(
|
||||
locations=location_responses,
|
||||
total=len(location_responses)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant locations failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant locations failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False), response_model=TenantLocationResponse)
|
||||
@track_endpoint_metrics("tenant_location_get")
|
||||
async def get_tenant_location(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_id: UUID = Path(..., description="Location ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Get a specific location for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant
|
||||
location_id: ID of the location to retrieve
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Get the specific location
|
||||
location = await location_repo.get_location_by_id(str(location_id))
|
||||
|
||||
if not location:
|
||||
logger.warning(
|
||||
"Location not found",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
# Verify that the location belongs to the specified tenant
|
||||
if str(location.tenant_id) != str(tenant_id):
|
||||
logger.warning(
|
||||
"Location does not belong to tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
location_tenant_id=str(location.tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Get tenant location successful",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(location.id),
|
||||
'tenant_id': str(location.tenant_id),
|
||||
'name': location.name,
|
||||
'location_type': location.location_type,
|
||||
'address': location.address,
|
||||
'city': location.city,
|
||||
'postal_code': location.postal_code,
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'contact_person': location.contact_person,
|
||||
'contact_phone': location.contact_phone,
|
||||
'contact_email': location.contact_email,
|
||||
'is_active': location.is_active,
|
||||
'delivery_windows': location.delivery_windows,
|
||||
'operational_hours': location.operational_hours,
|
||||
'capacity': location.capacity,
|
||||
'max_delivery_radius_km': location.max_delivery_radius_km,
|
||||
'delivery_schedule_config': location.delivery_schedule_config,
|
||||
'metadata': location.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': location.created_at,
|
||||
'updated_at': location.updated_at
|
||||
}
|
||||
return TenantLocationResponse.model_validate(loc_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.post(route_builder.build_base_route("{tenant_id}/locations", include_tenant_prefix=False), response_model=TenantLocationResponse)
|
||||
@admin_role_required
|
||||
async def create_tenant_location(
|
||||
location_data: TenantLocationCreate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Create a new location for a tenant.
|
||||
Requires admin or owner privileges.
|
||||
|
||||
Args:
|
||||
location_data: Location data to create
|
||||
tenant_id: ID of the tenant to create location for
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Create tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Verify that the tenant_id in the path matches the one in the data
|
||||
if str(tenant_id) != location_data.tenant_id:
|
||||
logger.warning(
|
||||
"Tenant ID mismatch",
|
||||
path_tenant_id=str(tenant_id),
|
||||
data_tenant_id=location_data.tenant_id,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant ID in path does not match data"
|
||||
)
|
||||
|
||||
# Prepare location data by excluding unset values
|
||||
location_dict = location_data.model_dump(exclude_unset=True)
|
||||
# Ensure tenant_id comes from the path for security
|
||||
location_dict['tenant_id'] = str(tenant_id)
|
||||
|
||||
created_location = await location_repo.create_location(location_dict)
|
||||
|
||||
logger.info(
|
||||
"Created tenant location successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(created_location.id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(created_location.id),
|
||||
'tenant_id': str(created_location.tenant_id),
|
||||
'name': created_location.name,
|
||||
'location_type': created_location.location_type,
|
||||
'address': created_location.address,
|
||||
'city': created_location.city,
|
||||
'postal_code': created_location.postal_code,
|
||||
'latitude': created_location.latitude,
|
||||
'longitude': created_location.longitude,
|
||||
'contact_person': created_location.contact_person,
|
||||
'contact_phone': created_location.contact_phone,
|
||||
'contact_email': created_location.contact_email,
|
||||
'is_active': created_location.is_active,
|
||||
'delivery_windows': created_location.delivery_windows,
|
||||
'operational_hours': created_location.operational_hours,
|
||||
'capacity': created_location.capacity,
|
||||
'max_delivery_radius_km': created_location.max_delivery_radius_km,
|
||||
'delivery_schedule_config': created_location.delivery_schedule_config,
|
||||
'metadata': created_location.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': created_location.created_at,
|
||||
'updated_at': created_location.updated_at
|
||||
}
|
||||
return TenantLocationResponse.model_validate(loc_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Create tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Create tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.put(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False), response_model=TenantLocationResponse)
|
||||
@admin_role_required
|
||||
async def update_tenant_location(
|
||||
update_data: TenantLocationUpdate,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_id: UUID = Path(..., description="Location ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Update a tenant location.
|
||||
Requires admin or owner privileges.
|
||||
|
||||
Args:
|
||||
update_data: Location data to update
|
||||
tenant_id: ID of the tenant
|
||||
location_id: ID of the location to update
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Update tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Check if the location exists and belongs to the tenant
|
||||
existing_location = await location_repo.get_location_by_id(str(location_id))
|
||||
if not existing_location:
|
||||
logger.warning(
|
||||
"Location not found for update",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
if str(existing_location.tenant_id) != str(tenant_id):
|
||||
logger.warning(
|
||||
"Location does not belong to tenant for update",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
location_tenant_id=str(existing_location.tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
# Prepare update data by excluding unset values
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
updated_location = await location_repo.update_location(str(location_id), update_dict)
|
||||
|
||||
if not updated_location:
|
||||
logger.error(
|
||||
"Failed to update location (not found after verification)",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated tenant location successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(updated_location.id),
|
||||
'tenant_id': str(updated_location.tenant_id),
|
||||
'name': updated_location.name,
|
||||
'location_type': updated_location.location_type,
|
||||
'address': updated_location.address,
|
||||
'city': updated_location.city,
|
||||
'postal_code': updated_location.postal_code,
|
||||
'latitude': updated_location.latitude,
|
||||
'longitude': updated_location.longitude,
|
||||
'contact_person': updated_location.contact_person,
|
||||
'contact_phone': updated_location.contact_phone,
|
||||
'contact_email': updated_location.contact_email,
|
||||
'is_active': updated_location.is_active,
|
||||
'delivery_windows': updated_location.delivery_windows,
|
||||
'operational_hours': updated_location.operational_hours,
|
||||
'capacity': updated_location.capacity,
|
||||
'max_delivery_radius_km': updated_location.max_delivery_radius_km,
|
||||
'delivery_schedule_config': updated_location.delivery_schedule_config,
|
||||
'metadata': updated_location.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': updated_location.created_at,
|
||||
'updated_at': updated_location.updated_at
|
||||
}
|
||||
return TenantLocationResponse.model_validate(loc_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Update tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Update tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(route_builder.build_base_route("{tenant_id}/locations/{location_id}", include_tenant_prefix=False))
|
||||
@admin_role_required
|
||||
async def delete_tenant_location(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_id: UUID = Path(..., description="Location ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Delete a tenant location.
|
||||
Requires admin or owner privileges.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant
|
||||
location_id: ID of the location to delete
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Delete tenant location request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Check if the location exists and belongs to the tenant
|
||||
existing_location = await location_repo.get_location_by_id(str(location_id))
|
||||
if not existing_location:
|
||||
logger.warning(
|
||||
"Location not found for deletion",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
if str(existing_location.tenant_id) != str(tenant_id):
|
||||
logger.warning(
|
||||
"Location does not belong to tenant for deletion",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
location_tenant_id=str(existing_location.tenant_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
deleted = await location_repo.delete_location(str(location_id))
|
||||
|
||||
if not deleted:
|
||||
logger.warning(
|
||||
"Location not found for deletion (race condition)",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Location not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Deleted tenant location successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Location deleted successfully",
|
||||
"location_id": str(location_id)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Delete tenant location failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_id=str(location_id),
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Delete tenant location failed"
|
||||
)
|
||||
|
||||
|
||||
@router.get(route_builder.build_base_route("{tenant_id}/locations/type/{location_type}", include_tenant_prefix=False), response_model=TenantLocationsResponse)
|
||||
@track_endpoint_metrics("tenant_locations_by_type")
|
||||
async def get_tenant_locations_by_type(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
location_type: str = Path(..., description="Location type to filter by", pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$'),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
location_repo: TenantLocationRepository = Depends(get_tenant_location_repository)
|
||||
):
|
||||
"""
|
||||
Get all locations of a specific type for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: ID of the tenant to get locations for
|
||||
location_type: Type of location to filter by
|
||||
current_user: Current user making the request
|
||||
location_repo: Tenant location repository instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Get tenant locations by type request received",
|
||||
tenant_id=str(tenant_id),
|
||||
location_type=location_type,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Use the method that returns multiple locations by types
|
||||
location_list = await location_repo.get_locations_by_tenant_with_type(str(tenant_id), [location_type])
|
||||
|
||||
logger.debug(
|
||||
"Get tenant locations by type successful",
|
||||
tenant_id=str(tenant_id),
|
||||
location_type=location_type,
|
||||
location_count=len(location_list)
|
||||
)
|
||||
|
||||
# Convert to response format - handle metadata field to avoid SQLAlchemy conflicts
|
||||
location_responses = []
|
||||
for loc in location_list:
|
||||
# Create dict from ORM object manually to handle metadata field properly
|
||||
loc_dict = {
|
||||
'id': str(loc.id),
|
||||
'tenant_id': str(loc.tenant_id),
|
||||
'name': loc.name,
|
||||
'location_type': loc.location_type,
|
||||
'address': loc.address,
|
||||
'city': loc.city,
|
||||
'postal_code': loc.postal_code,
|
||||
'latitude': loc.latitude,
|
||||
'longitude': loc.longitude,
|
||||
'contact_person': loc.contact_person,
|
||||
'contact_phone': loc.contact_phone,
|
||||
'contact_email': loc.contact_email,
|
||||
'is_active': loc.is_active,
|
||||
'delivery_windows': loc.delivery_windows,
|
||||
'operational_hours': loc.operational_hours,
|
||||
'capacity': loc.capacity,
|
||||
'max_delivery_radius_km': loc.max_delivery_radius_km,
|
||||
'delivery_schedule_config': loc.delivery_schedule_config,
|
||||
'metadata': loc.metadata_, # Use the actual column name to avoid conflict
|
||||
'created_at': loc.created_at,
|
||||
'updated_at': loc.updated_at
|
||||
}
|
||||
location_responses.append(TenantLocationResponse.model_validate(loc_dict))
|
||||
|
||||
return TenantLocationsResponse(
|
||||
locations=location_responses,
|
||||
total=len(location_responses)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Get tenant locations by type failed",
|
||||
tenant_id=str(tenant_id),
|
||||
location_type=location_type,
|
||||
user_id=current_user.get("user_id"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Get tenant locations by type failed"
|
||||
)
|
||||
@@ -290,13 +290,46 @@ async def get_user_owned_tenants(
|
||||
|
||||
# Users can only get their own tenants unless they're admin
|
||||
user_role = current_user.get('role', '').lower()
|
||||
if user_id != current_user["user_id"] and user_role != 'admin':
|
||||
|
||||
# Handle demo user: frontend uses "demo-user" but backend has actual demo user UUID
|
||||
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"
|
||||
)
|
||||
|
||||
tenants = await tenant_service.get_user_tenants(user_id)
|
||||
# For demo sessions, we need to handle the special case where virtual tenants are not owned by the
|
||||
# demo user ID but are instead associated with the demo session
|
||||
if current_user.get("is_demo", False):
|
||||
# Extract demo session info from headers (gateway should set this when processing demo tokens)
|
||||
demo_session_id = current_user.get("demo_session_id")
|
||||
demo_account_type = current_user.get("demo_account_type", "")
|
||||
|
||||
if demo_session_id:
|
||||
# For demo sessions, get virtual tenants associated with the session
|
||||
# Rather than returning all tenants owned by the shared demo user ID
|
||||
logger.info("Fetching virtual tenants for demo session",
|
||||
demo_session_id=demo_session_id,
|
||||
demo_account_type=demo_account_type)
|
||||
|
||||
# Special logic for demo sessions: return virtual tenants associated with this session
|
||||
virtual_tenants = await tenant_service.get_virtual_tenants_for_session(demo_session_id, demo_account_type)
|
||||
return virtual_tenants
|
||||
else:
|
||||
# Fallback: if no session ID but is a demo user, return based on account type
|
||||
# Individual bakery demo user should have access to the professional demo tenant
|
||||
# Enterprise demo session should have access only to enterprise parent tenant and its child
|
||||
virtual_tenants = await tenant_service.get_demo_tenants_by_session_type(
|
||||
demo_account_type,
|
||||
str(current_user["user_id"])
|
||||
)
|
||||
return virtual_tenants
|
||||
|
||||
# For regular users, use the original logic
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user