New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

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,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

@@ -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])

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations
from shared.service_base import StandardFastAPIService
@@ -122,6 +122,8 @@ service.add_router(tenants.router, tags=["tenants"])
service.add_router(tenant_members.router, tags=["tenant-members"])
service.add_router(tenant_operations.router, tags=["tenant-operations"])
service.add_router(webhooks.router, tags=["webhooks"])
service.add_router(enterprise_upgrade.router, tags=["enterprise"]) # Enterprise tier upgrade endpoints
service.add_router(tenant_locations.router, tags=["tenant-locations"]) # Tenant locations endpoints
service.add_router(internal_demo.router, tags=["internal"])
if __name__ == "__main__":

View File

@@ -13,6 +13,7 @@ AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .tenants import Tenant, TenantMember, Subscription
from .tenant_location import TenantLocation
from .coupon import CouponModel, CouponRedemptionModel
from .events import Event, EventTemplate
@@ -21,6 +22,7 @@ __all__ = [
"Tenant",
"TenantMember",
"Subscription",
"TenantLocation",
"AuditLog",
"CouponModel",
"CouponRedemptionModel",

View File

@@ -0,0 +1,59 @@
"""
Tenant Location Model
Represents physical locations for enterprise tenants (central production, retail outlets)
"""
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
import uuid
from shared.database.base import Base
class TenantLocation(Base):
"""TenantLocation model - represents physical locations for enterprise tenants"""
__tablename__ = "tenant_locations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
# Location information
name = Column(String(200), nullable=False)
location_type = Column(String(50), nullable=False) # central_production, retail_outlet
address = Column(Text, nullable=False)
city = Column(String(100), default="Madrid")
postal_code = Column(String(10), nullable=False)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
# Location-specific configuration
delivery_windows = Column(JSON, nullable=True) # { "monday": "08:00-12:00,14:00-18:00", ... }
capacity = Column(Integer, nullable=True) # For production capacity in kg/day or storage capacity
max_delivery_radius_km = Column(Float, nullable=True, default=50.0)
# Operational hours
operational_hours = Column(JSON, nullable=True) # { "monday": "06:00-20:00", ... }
is_active = Column(Boolean, default=True)
# Contact information
contact_person = Column(String(200), nullable=True)
contact_phone = Column(String(20), nullable=True)
contact_email = Column(String(255), nullable=True)
# Custom delivery scheduling configuration per location
delivery_schedule_config = Column(JSON, nullable=True) # { "delivery_days": "Mon,Wed,Fri", "time_window": "07:00-10:00" }
# Metadata
metadata_ = Column(JSON, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
tenant = relationship("Tenant", back_populates="locations")
def __repr__(self):
return f"<TenantLocation(id={self.id}, tenant_id={self.tenant_id}, name={self.name}, type={self.location_type})>"

View File

@@ -56,6 +56,11 @@ class Tenant(Base):
# Ownership (user_id without FK - cross-service reference)
owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Enterprise tier hierarchy fields
parent_tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=True, index=True)
tenant_type = Column(String(50), default="standalone", nullable=False) # standalone, parent, child
hierarchy_path = Column(String(500), nullable=True) # Materialized path for queries
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -63,6 +68,9 @@ class Tenant(Base):
# Relationships - only within tenant service
members = relationship("TenantMember", back_populates="tenant", cascade="all, delete-orphan")
subscriptions = relationship("Subscription", back_populates="tenant", cascade="all, delete-orphan")
locations = relationship("TenantLocation", back_populates="tenant", cascade="all, delete-orphan")
child_tenants = relationship("Tenant", back_populates="parent_tenant", remote_side=[id])
parent_tenant = relationship("Tenant", back_populates="child_tenants", remote_side=[parent_tenant_id])
# REMOVED: users relationship - no cross-service SQLAlchemy relationships
@@ -115,7 +123,7 @@ class TenantMember(Base):
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference
# Role and permissions specific to this tenant
# Valid values: 'owner', 'admin', 'member', 'viewer'
# Valid values: 'owner', 'admin', 'member', 'viewer', 'network_admin'
role = Column(String(50), default="member")
permissions = Column(Text) # JSON string of permissions

View File

@@ -0,0 +1,218 @@
"""
Tenant Location Repository
Handles database operations for tenant location data
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
import structlog
from app.models.tenant_location import TenantLocation
from app.models.tenants import Tenant
from shared.database.exceptions import DatabaseError
from .base import BaseRepository
logger = structlog.get_logger()
class TenantLocationRepository(BaseRepository):
"""Repository for tenant location operations"""
def __init__(self, session: AsyncSession):
super().__init__(TenantLocation, session)
async def create_location(self, location_data: Dict[str, Any]) -> TenantLocation:
"""
Create a new tenant location
Args:
location_data: Dictionary containing location information
Returns:
Created TenantLocation object
"""
try:
# Create new location instance
location = TenantLocation(**location_data)
self.session.add(location)
await self.session.commit()
await self.session.refresh(location)
logger.info(f"Created new tenant location: {location.id} for tenant {location.tenant_id}")
return location
except Exception as e:
await self.session.rollback()
logger.error(f"Failed to create tenant location: {str(e)}")
raise DatabaseError(f"Failed to create tenant location: {str(e)}")
async def get_location_by_id(self, location_id: str) -> Optional[TenantLocation]:
"""
Get a location by its ID
Args:
location_id: UUID of the location
Returns:
TenantLocation object if found, None otherwise
"""
try:
stmt = select(TenantLocation).where(TenantLocation.id == location_id)
result = await self.session.execute(stmt)
location = result.scalar_one_or_none()
return location
except Exception as e:
logger.error(f"Failed to get location by ID: {str(e)}")
raise DatabaseError(f"Failed to get location by ID: {str(e)}")
async def get_locations_by_tenant(self, tenant_id: str) -> List[TenantLocation]:
"""
Get all locations for a specific tenant
Args:
tenant_id: UUID of the tenant
Returns:
List of TenantLocation objects
"""
try:
stmt = select(TenantLocation).where(TenantLocation.tenant_id == tenant_id)
result = await self.session.execute(stmt)
locations = result.scalars().all()
return locations
except Exception as e:
logger.error(f"Failed to get locations by tenant: {str(e)}")
raise DatabaseError(f"Failed to get locations by tenant: {str(e)}")
async def get_location_by_type(self, tenant_id: str, location_type: str) -> Optional[TenantLocation]:
"""
Get a location by tenant and type
Args:
tenant_id: UUID of the tenant
location_type: Type of location (e.g., 'central_production', 'retail_outlet')
Returns:
TenantLocation object if found, None otherwise
"""
try:
stmt = select(TenantLocation).where(
TenantLocation.tenant_id == tenant_id,
TenantLocation.location_type == location_type
)
result = await self.session.execute(stmt)
location = result.scalar_one_or_none()
return location
except Exception as e:
logger.error(f"Failed to get location by type: {str(e)}")
raise DatabaseError(f"Failed to get location by type: {str(e)}")
async def update_location(self, location_id: str, location_data: Dict[str, Any]) -> Optional[TenantLocation]:
"""
Update a tenant location
Args:
location_id: UUID of the location to update
location_data: Dictionary containing updated location information
Returns:
Updated TenantLocation object if successful, None if location not found
"""
try:
stmt = (
update(TenantLocation)
.where(TenantLocation.id == location_id)
.values(**location_data)
)
await self.session.execute(stmt)
# Now fetch the updated location
location_stmt = select(TenantLocation).where(TenantLocation.id == location_id)
result = await self.session.execute(location_stmt)
location = result.scalar_one_or_none()
if location:
await self.session.commit()
logger.info(f"Updated tenant location: {location_id}")
return location
else:
await self.session.rollback()
logger.warning(f"Location not found for update: {location_id}")
return None
except Exception as e:
await self.session.rollback()
logger.error(f"Failed to update location: {str(e)}")
raise DatabaseError(f"Failed to update location: {str(e)}")
async def delete_location(self, location_id: str) -> bool:
"""
Delete a tenant location
Args:
location_id: UUID of the location to delete
Returns:
True if deleted successfully, False if location not found
"""
try:
stmt = delete(TenantLocation).where(TenantLocation.id == location_id)
result = await self.session.execute(stmt)
if result.rowcount > 0:
await self.session.commit()
logger.info(f"Deleted tenant location: {location_id}")
return True
else:
await self.session.rollback()
logger.warning(f"Location not found for deletion: {location_id}")
return False
except Exception as e:
await self.session.rollback()
logger.error(f"Failed to delete location: {str(e)}")
raise DatabaseError(f"Failed to delete location: {str(e)}")
async def get_active_locations_by_tenant(self, tenant_id: str) -> List[TenantLocation]:
"""
Get all active locations for a specific tenant
Args:
tenant_id: UUID of the tenant
Returns:
List of active TenantLocation objects
"""
try:
stmt = select(TenantLocation).where(
TenantLocation.tenant_id == tenant_id,
TenantLocation.is_active == True
)
result = await self.session.execute(stmt)
locations = result.scalars().all()
return locations
except Exception as e:
logger.error(f"Failed to get active locations by tenant: {str(e)}")
raise DatabaseError(f"Failed to get active locations by tenant: {str(e)}")
async def get_locations_by_tenant_with_type(self, tenant_id: str, location_types: List[str]) -> List[TenantLocation]:
"""
Get locations for a specific tenant filtered by location types
Args:
tenant_id: UUID of the tenant
location_types: List of location types to filter by
Returns:
List of TenantLocation objects matching the criteria
"""
try:
stmt = select(TenantLocation).where(
TenantLocation.tenant_id == tenant_id,
TenantLocation.location_type.in_(location_types)
)
result = await self.session.execute(stmt)
locations = result.scalars().all()
return locations
except Exception as e:
logger.error(f"Failed to get locations by tenant and type: {str(e)}")
raise DatabaseError(f"Failed to get locations by tenant and type: {str(e)}")

View File

@@ -381,3 +381,188 @@ class TenantRepository(TenantBaseRepository):
async def activate_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""Activate a tenant"""
return await self.activate_record(tenant_id)
async def get_child_tenants(self, parent_tenant_id: str) -> List[Tenant]:
"""Get all child tenants for a parent tenant"""
try:
return await self.get_multi(
filters={"parent_tenant_id": parent_tenant_id, "is_active": True},
order_by="created_at",
order_desc=False
)
except Exception as e:
logger.error("Failed to get child tenants",
parent_tenant_id=parent_tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get child tenants: {str(e)}")
async def get_child_tenant_count(self, parent_tenant_id: str) -> int:
"""Get count of child tenants for a parent tenant"""
try:
child_tenants = await self.get_child_tenants(parent_tenant_id)
return len(child_tenants)
except Exception as e:
logger.error("Failed to get child tenant count",
parent_tenant_id=parent_tenant_id,
error=str(e))
return 0
async def get_user_tenants_with_hierarchy(self, user_id: str) -> List[Dict[str, Any]]:
"""
Get all tenants a user has access to, organized in hierarchy.
Returns parent tenants with their children nested.
"""
try:
# Get all tenants where user is owner or member
query_text = """
SELECT DISTINCT t.*
FROM tenants t
LEFT JOIN tenant_members tm ON t.id = tm.tenant_id
WHERE (t.owner_id = :user_id OR tm.user_id = :user_id)
AND t.is_active = true
ORDER BY t.tenant_type DESC, t.created_at ASC
"""
result = await self.session.execute(text(query_text), {"user_id": user_id})
tenants = []
for row in result.fetchall():
record_dict = dict(row._mapping)
tenant = self.model(**record_dict)
tenants.append(tenant)
# Organize into hierarchy
tenant_hierarchy = []
parent_map = {}
# First pass: collect all parent/standalone tenants
for tenant in tenants:
if tenant.tenant_type in ['parent', 'standalone']:
tenant_dict = {
'id': str(tenant.id),
'name': tenant.name,
'subdomain': tenant.subdomain,
'tenant_type': tenant.tenant_type,
'business_type': tenant.business_type,
'business_model': tenant.business_model,
'city': tenant.city,
'is_active': tenant.is_active,
'children': [] if tenant.tenant_type == 'parent' else None
}
tenant_hierarchy.append(tenant_dict)
parent_map[str(tenant.id)] = tenant_dict
# Second pass: attach children to their parents
for tenant in tenants:
if tenant.tenant_type == 'child' and tenant.parent_tenant_id:
parent_id = str(tenant.parent_tenant_id)
if parent_id in parent_map:
child_dict = {
'id': str(tenant.id),
'name': tenant.name,
'subdomain': tenant.subdomain,
'tenant_type': 'child',
'parent_tenant_id': parent_id,
'city': tenant.city,
'is_active': tenant.is_active
}
parent_map[parent_id]['children'].append(child_dict)
return tenant_hierarchy
except Exception as e:
logger.error("Failed to get user tenants with hierarchy",
user_id=user_id,
error=str(e))
return []
async def get_tenants_by_session_id(self, session_id: str) -> List[Tenant]:
"""
Get tenants associated with a specific demo session using the demo_session_id field.
"""
try:
return await self.get_multi(
filters={
"demo_session_id": session_id,
"is_active": True
},
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenants by session ID",
session_id=session_id,
error=str(e))
raise DatabaseError(f"Failed to get tenants by session ID: {str(e)}")
async def get_professional_demo_tenants(self, session_id: str) -> List[Tenant]:
"""
Get professional demo tenants filtered by session.
Args:
session_id: Required demo session ID to filter tenants
Returns:
List of professional demo tenants for this specific session
"""
try:
filters = {
"business_model": "professional_bakery",
"is_demo": True,
"is_active": True,
"demo_session_id": session_id # Always filter by session
}
return await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get professional demo tenants",
session_id=session_id,
error=str(e))
raise DatabaseError(f"Failed to get professional demo tenants: {str(e)}")
async def get_enterprise_demo_tenants(self, session_id: str) -> List[Tenant]:
"""
Get enterprise demo tenants (parent and children) filtered by session.
Args:
session_id: Required demo session ID to filter tenants
Returns:
List of enterprise demo tenants (1 parent + 3 children) for this specific session
"""
try:
# Get enterprise demo parent tenants for this session
parent_tenants = await self.get_multi(
filters={
"tenant_type": "parent",
"is_demo": True,
"is_active": True,
"demo_session_id": session_id # Always filter by session
},
order_by="created_at",
order_desc=True
)
# Get child tenants for the enterprise demo session
child_tenants = await self.get_multi(
filters={
"tenant_type": "child",
"is_demo": True,
"is_active": True,
"demo_session_id": session_id # Always filter by session
},
order_by="created_at",
order_desc=True
)
# Combine parent and child tenants
return parent_tenants + child_tenants
except Exception as e:
logger.error("Failed to get enterprise demo tenants",
session_id=session_id,
error=str(e))
raise DatabaseError(f"Failed to get enterprise demo tenants: {str(e)}")

View File

@@ -0,0 +1,89 @@
"""
Tenant Location Schemas
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from uuid import UUID
class TenantLocationBase(BaseModel):
"""Base schema for tenant location"""
name: str = Field(..., min_length=1, max_length=200)
location_type: str = Field(..., pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$')
address: str = Field(..., min_length=10, max_length=500)
city: str = Field(default="Madrid", max_length=100)
postal_code: str = Field(..., min_length=3, max_length=10)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
contact_person: Optional[str] = Field(None, max_length=200)
contact_phone: Optional[str] = Field(None, max_length=20)
contact_email: Optional[str] = Field(None, max_length=255)
is_active: bool = True
delivery_windows: Optional[Dict[str, Any]] = None
operational_hours: Optional[Dict[str, Any]] = None
capacity: Optional[int] = Field(None, ge=0)
max_delivery_radius_km: Optional[float] = Field(None, ge=0)
delivery_schedule_config: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = Field(None)
class TenantLocationCreate(TenantLocationBase):
"""Schema for creating a tenant location"""
tenant_id: str # This will be validated as UUID in the API layer
class TenantLocationUpdate(BaseModel):
"""Schema for updating a tenant location"""
name: Optional[str] = Field(None, min_length=1, max_length=200)
location_type: Optional[str] = Field(None, pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$')
address: Optional[str] = Field(None, min_length=10, max_length=500)
city: Optional[str] = Field(None, max_length=100)
postal_code: Optional[str] = Field(None, min_length=3, max_length=10)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
contact_person: Optional[str] = Field(None, max_length=200)
contact_phone: Optional[str] = Field(None, max_length=20)
contact_email: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
delivery_windows: Optional[Dict[str, Any]] = None
operational_hours: Optional[Dict[str, Any]] = None
capacity: Optional[int] = Field(None, ge=0)
max_delivery_radius_km: Optional[float] = Field(None, ge=0)
delivery_schedule_config: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = Field(None)
class TenantLocationResponse(TenantLocationBase):
"""Schema for tenant location response"""
id: str
tenant_id: str
created_at: datetime
updated_at: Optional[datetime]
@field_validator('id', 'tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""
if isinstance(v, UUID):
return str(v)
return v
class Config:
from_attributes = True
populate_by_name = True
class TenantLocationsResponse(BaseModel):
"""Schema for multiple tenant locations response"""
locations: List[TenantLocationResponse]
total: int
class TenantLocationTypeFilter(BaseModel):
"""Schema for filtering locations by type"""
location_types: List[str] = Field(
default=["central_production", "retail_outlet", "warehouse", "store", "branch"],
description="List of location types to include"
)

View File

@@ -63,6 +63,8 @@ class TenantResponse(BaseModel):
subdomain: Optional[str]
business_type: str
business_model: Optional[str]
tenant_type: Optional[str] = "standalone" # standalone, parent, or child
parent_tenant_id: Optional[str] = None # For child tenants
address: str
city: str
postal_code: str
@@ -75,7 +77,7 @@ class TenantResponse(BaseModel):
created_at: datetime
# ✅ FIX: Add custom validator to convert UUID to string
@field_validator('id', 'owner_id', mode='before')
@field_validator('id', 'owner_id', 'parent_tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""

View File

@@ -314,6 +314,120 @@ class EnhancedTenantService:
error=str(e))
return []
async def get_virtual_tenants_for_session(self, demo_session_id: str, demo_account_type: str) -> List[TenantResponse]:
"""
Get virtual tenants associated with a specific demo session.
This method handles the special demo session access patterns:
- Individual bakery demo user: should have access to professional demo tenant (1 tenant)
- Enterprise demo session: should have access to parent tenant and its children (4 tenants)
Now properly filters by demo_session_id field which is populated during tenant cloning.
"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
# Query all tenants by demo_session_id (now properly populated during cloning)
virtual_tenants = await self.tenant_repo.get_tenants_by_session_id(demo_session_id)
if not virtual_tenants:
logger.warning(
"No virtual tenants found for demo session - session may not exist or tenants not yet created",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type
)
return []
logger.info(
"Retrieved virtual tenants for demo session",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type,
tenant_count=len(virtual_tenants)
)
return [TenantResponse.from_orm(tenant) for tenant in virtual_tenants]
except Exception as e:
logger.error("Error getting virtual tenants for demo session",
demo_session_id=demo_session_id,
demo_account_type=demo_account_type,
error=str(e))
# Fallback: return empty list instead of all demo tenants
return []
async def get_demo_tenants_by_session_type(self, demo_account_type: str, current_user_id: str) -> List[TenantResponse]:
"""
DEPRECATED: Fallback method for old demo sessions without demo_session_id.
Get demo tenants based on session type rather than user ownership.
This implements the specific requirements:
- Individual bakery demo user: access to professional demo tenant
- Enterprise demo session: access only to enterprise parent tenant and its child
WARNING: This method returns ALL demo tenants of a given type, not session-specific ones.
New code should use get_virtual_tenants_for_session() instead.
"""
logger.warning(
"Using deprecated fallback method - demo_session_id not available",
demo_account_type=demo_account_type,
user_id=current_user_id
)
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
if demo_account_type.lower() == 'professional_bakery':
# Individual bakery demo user should have access to professional demo tenant
# Return demo tenants with business_model='professional_bakery' that are demo tenants
tenants = await self.tenant_repo.get_multi(
filters={
"business_model": "professional_bakery",
"is_demo": True,
"is_active": True
}
)
elif demo_account_type.lower() in ['enterprise_chain', 'enterprise_parent']:
# Enterprise demo session should have access to parent tenant and its children
# Return demo tenants with tenant_type in ['parent', 'child'] that are demo tenants
parent_tenants = await self.tenant_repo.get_multi(
filters={
"tenant_type": "parent",
"is_demo": True,
"is_active": True
}
)
child_tenants = await self.tenant_repo.get_multi(
filters={
"tenant_type": "child",
"is_demo": True,
"is_active": True
}
)
tenants = parent_tenants + child_tenants
elif demo_account_type.lower() == 'enterprise_child':
# For child enterprise sessions, return only child demo tenants
tenants = await self.tenant_repo.get_multi(
filters={
"tenant_type": "child",
"is_demo": True,
"is_active": True
}
)
else:
# Default case - return the user's actual owned tenants
tenants = await self.tenant_repo.get_tenants_by_owner(current_user_id)
return [TenantResponse.from_orm(tenant) for tenant in tenants]
except Exception as e:
logger.error("Error getting demo tenants by session type",
demo_account_type=demo_account_type,
user_id=current_user_id,
error=str(e))
# Fallback: return empty list
return []
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> List[TenantResponse]:
"""Get all active tenants"""