1610 lines
65 KiB
Python
1610 lines
65 KiB
Python
"""
|
|
Enhanced Tenant Service
|
|
Business logic layer using repository pattern for tenant operations
|
|
"""
|
|
|
|
import structlog
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List, Dict, Any
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from fastapi import HTTPException, status
|
|
|
|
from app.repositories import TenantRepository, TenantMemberRepository, SubscriptionRepository
|
|
from app.models.tenants import Tenant, TenantMember, Subscription
|
|
from app.schemas.tenants import (
|
|
BakeryRegistration, TenantResponse, TenantAccessResponse,
|
|
TenantUpdate, TenantMemberResponse
|
|
)
|
|
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
|
|
from shared.database.base import create_database_manager
|
|
from shared.database.unit_of_work import UnitOfWork
|
|
from shared.clients.nominatim_client import NominatimClient
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class EnhancedTenantService:
|
|
"""Enhanced tenant management business logic using repository pattern with dependency injection"""
|
|
|
|
def __init__(self, database_manager=None, event_publisher=None):
|
|
self.database_manager = database_manager or create_database_manager()
|
|
self.event_publisher = event_publisher
|
|
|
|
async def _init_repositories(self, session):
|
|
"""Initialize repositories with session"""
|
|
self.tenant_repo = TenantRepository(Tenant, session)
|
|
self.member_repo = TenantMemberRepository(TenantMember, session)
|
|
self.subscription_repo = SubscriptionRepository(Subscription, session)
|
|
return {
|
|
'tenant': self.tenant_repo,
|
|
'member': self.member_repo,
|
|
'subscription': self.subscription_repo
|
|
}
|
|
|
|
async def create_bakery(
|
|
self,
|
|
bakery_data: BakeryRegistration,
|
|
owner_id: str,
|
|
session=None
|
|
) -> TenantResponse:
|
|
"""Create a new bakery/tenant with enhanced validation and features using repository pattern"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
async with UnitOfWork(db_session) as uow:
|
|
# Register repositories
|
|
tenant_repo = uow.register_repository("tenants", TenantRepository, Tenant)
|
|
member_repo = uow.register_repository("members", TenantMemberRepository, TenantMember)
|
|
subscription_repo = uow.register_repository("subscriptions", SubscriptionRepository, Subscription)
|
|
|
|
# Geocode address using Nominatim
|
|
latitude = getattr(bakery_data, 'latitude', None)
|
|
longitude = getattr(bakery_data, 'longitude', None)
|
|
|
|
if not latitude or not longitude:
|
|
try:
|
|
from app.core.config import settings
|
|
nominatim_client = NominatimClient(settings)
|
|
|
|
location = await nominatim_client.geocode_address(
|
|
street=bakery_data.address,
|
|
city=bakery_data.city,
|
|
postal_code=bakery_data.postal_code,
|
|
country="Spain"
|
|
)
|
|
|
|
if location:
|
|
latitude = float(location["lat"])
|
|
longitude = float(location["lon"])
|
|
logger.info(
|
|
"Address geocoded successfully",
|
|
address=bakery_data.address,
|
|
city=bakery_data.city,
|
|
latitude=latitude,
|
|
longitude=longitude
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Could not geocode address, using default Madrid coordinates",
|
|
address=bakery_data.address,
|
|
city=bakery_data.city
|
|
)
|
|
latitude = 40.4168
|
|
longitude = -3.7038
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Geocoding failed, using default coordinates",
|
|
address=bakery_data.address,
|
|
error=str(e)
|
|
)
|
|
latitude = 40.4168
|
|
longitude = -3.7038
|
|
|
|
# Prepare tenant data
|
|
tenant_data = {
|
|
"name": bakery_data.name,
|
|
"business_type": bakery_data.business_type,
|
|
"address": bakery_data.address,
|
|
"city": bakery_data.city,
|
|
"postal_code": bakery_data.postal_code,
|
|
"phone": bakery_data.phone,
|
|
"owner_id": owner_id,
|
|
"email": getattr(bakery_data, 'email', None),
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"is_active": True
|
|
}
|
|
|
|
# Create tenant using repository
|
|
tenant = await tenant_repo.create_tenant(tenant_data)
|
|
|
|
# Create owner membership
|
|
membership_data = {
|
|
"tenant_id": str(tenant.id),
|
|
"user_id": owner_id,
|
|
"role": "owner",
|
|
"is_active": True
|
|
}
|
|
|
|
owner_membership = await member_repo.create_membership(membership_data)
|
|
|
|
# Get subscription plan from user's registration using standardized auth client
|
|
selected_plan = "starter" # Default fallback
|
|
try:
|
|
from shared.clients.auth_client import AuthServiceClient
|
|
from app.core.config import settings
|
|
|
|
auth_client = AuthServiceClient(settings)
|
|
selected_plan = await auth_client.get_subscription_plan_from_registration(owner_id)
|
|
|
|
logger.info("Retrieved subscription plan from registration",
|
|
tenant_id=tenant.id,
|
|
owner_id=owner_id,
|
|
plan=selected_plan)
|
|
|
|
except Exception as e:
|
|
logger.warning("Could not retrieve subscription plan from auth service, using default",
|
|
error=str(e),
|
|
owner_id=owner_id,
|
|
default_plan=selected_plan)
|
|
|
|
# Create subscription with selected or default plan
|
|
# When tenant_id is set, is_tenant_linked must be True (database constraint)
|
|
subscription_data = {
|
|
"tenant_id": str(tenant.id),
|
|
"plan": selected_plan,
|
|
"status": "active",
|
|
"is_tenant_linked": True, # Required when tenant_id is set
|
|
"tenant_linking_status": "completed" # Mark as completed since tenant is already created
|
|
}
|
|
|
|
subscription = await subscription_repo.create_subscription(subscription_data)
|
|
|
|
logger.info("Subscription created",
|
|
tenant_id=tenant.id,
|
|
plan=selected_plan)
|
|
|
|
# Commit the transaction
|
|
await uow.commit()
|
|
|
|
# Publish tenant created event
|
|
if self.event_publisher:
|
|
try:
|
|
await self.event_publisher.publish_business_event(
|
|
event_type="tenant.created",
|
|
tenant_id=str(tenant.id),
|
|
data={
|
|
"tenant_id": str(tenant.id),
|
|
"owner_id": owner_id,
|
|
"name": bakery_data.name,
|
|
"created_at": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to publish tenant created event", error=str(e))
|
|
|
|
# Automatically create location-context with city information
|
|
# This is non-blocking - failure won't prevent tenant creation
|
|
try:
|
|
from shared.clients.external_client import ExternalServiceClient
|
|
from shared.utils.city_normalization import normalize_city_id
|
|
from app.core.config import settings
|
|
|
|
external_client = ExternalServiceClient(settings, "tenant")
|
|
city_id = normalize_city_id(bakery_data.city)
|
|
|
|
if city_id:
|
|
await external_client.create_tenant_location_context(
|
|
tenant_id=str(tenant.id),
|
|
city_id=city_id,
|
|
notes="Auto-created during tenant registration"
|
|
)
|
|
logger.info(
|
|
"Automatically created location-context",
|
|
tenant_id=str(tenant.id),
|
|
city_id=city_id
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Could not normalize city for location-context",
|
|
tenant_id=str(tenant.id),
|
|
city=bakery_data.city
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to auto-create location-context (non-blocking)",
|
|
tenant_id=str(tenant.id),
|
|
city=bakery_data.city,
|
|
error=str(e)
|
|
)
|
|
# Don't fail tenant creation if location-context creation fails
|
|
|
|
# Update user's tenant_id in auth service
|
|
try:
|
|
from shared.clients.auth_client import AuthServiceClient
|
|
from app.core.config import settings
|
|
|
|
auth_client = AuthServiceClient(settings)
|
|
await auth_client.update_user_tenant_id(owner_id, str(tenant.id))
|
|
|
|
logger.info("Updated user tenant_id in auth service",
|
|
user_id=owner_id,
|
|
tenant_id=str(tenant.id))
|
|
except Exception as e:
|
|
logger.error("Failed to update user tenant_id (non-blocking)",
|
|
user_id=owner_id,
|
|
tenant_id=str(tenant.id),
|
|
error=str(e))
|
|
# Don't fail tenant creation if user update fails
|
|
|
|
logger.info("Bakery created successfully",
|
|
tenant_id=tenant.id,
|
|
name=bakery_data.name,
|
|
owner_id=owner_id,
|
|
subdomain=tenant.subdomain)
|
|
|
|
return TenantResponse.from_orm(tenant)
|
|
|
|
except (ValidationError, DuplicateRecordError) as e:
|
|
logger.error("Validation error creating bakery",
|
|
name=bakery_data.name,
|
|
owner_id=owner_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error creating bakery",
|
|
name=bakery_data.name,
|
|
owner_id=owner_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to create bakery"
|
|
)
|
|
|
|
async def verify_user_access(
|
|
self,
|
|
user_id: str,
|
|
tenant_id: str
|
|
) -> TenantAccessResponse:
|
|
"""Verify if user has access to tenant with enhanced permissions"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
access_info = await self.member_repo.verify_user_access(user_id, tenant_id)
|
|
|
|
return TenantAccessResponse(
|
|
has_access=access_info["has_access"],
|
|
role=access_info["role"],
|
|
permissions=access_info["permissions"],
|
|
membership_id=access_info.get("membership_id"),
|
|
joined_at=access_info.get("joined_at")
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("Error verifying user access",
|
|
user_id=user_id,
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
return TenantAccessResponse(
|
|
has_access=False,
|
|
role="none",
|
|
permissions=[]
|
|
)
|
|
|
|
async def get_tenant_by_id(self, tenant_id: str) -> Optional[TenantResponse]:
|
|
"""Get tenant by ID with enhanced data"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
|
if tenant:
|
|
return TenantResponse.from_orm(tenant)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting tenant",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
return None
|
|
|
|
async def get_tenant_by_subdomain(self, subdomain: str) -> Optional[TenantResponse]:
|
|
"""Get tenant by subdomain"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
tenant = await self.tenant_repo.get_by_subdomain(subdomain)
|
|
if tenant:
|
|
return TenantResponse.from_orm(tenant)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting tenant by subdomain",
|
|
subdomain=subdomain,
|
|
error=str(e))
|
|
return None
|
|
|
|
async def get_user_tenants(self, user_id: str) -> List[TenantResponse]:
|
|
"""Get all tenants accessible by a user (both owned and member tenants)"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Get tenants where user is the owner
|
|
owned_tenants = await self.tenant_repo.get_tenants_by_owner(user_id)
|
|
|
|
# Get tenants where user is a member (but not owner)
|
|
memberships = await self.member_repo.get_user_memberships(user_id, active_only=True)
|
|
|
|
# Get tenant details for each membership
|
|
member_tenant_ids = [str(membership.tenant_id) for membership in memberships]
|
|
member_tenants = []
|
|
|
|
if member_tenant_ids:
|
|
# Get tenant details for each membership
|
|
for tenant_id in member_tenant_ids:
|
|
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
|
if tenant:
|
|
member_tenants.append(tenant)
|
|
|
|
# Combine and deduplicate (in case user is both owner and member)
|
|
all_tenants = owned_tenants + member_tenants
|
|
|
|
# Remove duplicates by tenant ID
|
|
unique_tenants = []
|
|
seen_ids = set()
|
|
for tenant in all_tenants:
|
|
if str(tenant.id) not in seen_ids:
|
|
seen_ids.add(str(tenant.id))
|
|
unique_tenants.append(tenant)
|
|
|
|
logger.info(
|
|
"Retrieved user tenants",
|
|
user_id=user_id,
|
|
owned_count=len(owned_tenants),
|
|
member_count=len(member_tenants),
|
|
total_count=len(unique_tenants)
|
|
)
|
|
|
|
return [TenantResponse.from_orm(tenant) for tenant in unique_tenants]
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting user tenants",
|
|
user_id=user_id,
|
|
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"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
tenants = await self.tenant_repo.get_active_tenants(skip=skip, limit=limit)
|
|
return [TenantResponse.from_orm(tenant) for tenant in tenants]
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting active tenants",
|
|
skip=skip,
|
|
limit=limit,
|
|
error=str(e))
|
|
return []
|
|
|
|
async def search_tenants(
|
|
self,
|
|
search_term: str,
|
|
business_type: str = None,
|
|
city: str = None,
|
|
skip: int = 0,
|
|
limit: int = 50
|
|
) -> List[TenantResponse]:
|
|
"""Search tenants with filters"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
tenants = await self.tenant_repo.search_tenants(
|
|
search_term, business_type, city, skip, limit
|
|
)
|
|
return [TenantResponse.from_orm(tenant) for tenant in tenants]
|
|
|
|
except Exception as e:
|
|
logger.error("Error searching tenants",
|
|
search_term=search_term,
|
|
error=str(e))
|
|
return []
|
|
|
|
async def update_tenant(
|
|
self,
|
|
tenant_id: str,
|
|
update_data: TenantUpdate,
|
|
user_id: str,
|
|
session: AsyncSession = None
|
|
) -> TenantResponse:
|
|
"""Update tenant information with permission checks"""
|
|
|
|
try:
|
|
# Verify user has admin access
|
|
access = await self.verify_user_access(user_id, tenant_id)
|
|
if not access.has_access or access.role not in ["owner", "admin"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions to update tenant"
|
|
)
|
|
|
|
# Update tenant using repository with proper session management
|
|
update_values = update_data.dict(exclude_unset=True)
|
|
if update_values:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
updated_tenant = await self.tenant_repo.update(tenant_id, update_values)
|
|
|
|
if not updated_tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
logger.info("Tenant updated successfully",
|
|
tenant_id=tenant_id,
|
|
updated_by=user_id,
|
|
fields=list(update_values.keys()))
|
|
|
|
return TenantResponse.from_orm(updated_tenant)
|
|
|
|
# No updates to apply - get current tenant data
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
|
return TenantResponse.from_orm(tenant)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error updating tenant",
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update tenant"
|
|
)
|
|
|
|
async def add_team_member(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
role: str,
|
|
invited_by: str,
|
|
session: AsyncSession = None
|
|
) -> TenantMemberResponse:
|
|
"""Add a team member to tenant with enhanced validation"""
|
|
|
|
try:
|
|
# Verify inviter has admin access
|
|
access = await self.verify_user_access(invited_by, tenant_id)
|
|
if not access.has_access or access.role not in ["owner", "admin"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions to add team members"
|
|
)
|
|
|
|
# Create membership using repository
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
membership_data = {
|
|
"tenant_id": tenant_id,
|
|
"user_id": user_id,
|
|
"role": role,
|
|
"invited_by": invited_by,
|
|
"is_active": True
|
|
}
|
|
|
|
member = await self.member_repo.create_membership(membership_data)
|
|
|
|
# Publish member added event
|
|
if self.event_publisher:
|
|
try:
|
|
await self.event_publisher.publish_business_event(
|
|
event_type="tenant.member.added",
|
|
tenant_id=tenant_id,
|
|
data={
|
|
"tenant_id": tenant_id,
|
|
"user_id": user_id,
|
|
"role": role,
|
|
"invited_by": invited_by,
|
|
"added_at": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to publish member added event", error=str(e))
|
|
|
|
logger.info("Team member added successfully",
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
role=role,
|
|
invited_by=invited_by)
|
|
|
|
return TenantMemberResponse.from_orm(member)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except (ValidationError, DuplicateRecordError) as e:
|
|
logger.error("Validation error adding team member",
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error adding team member",
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to add team member"
|
|
)
|
|
|
|
async def get_team_members(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
active_only: bool = True
|
|
) -> List[TenantMemberResponse]:
|
|
"""Get all team members for a tenant with enriched user information"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as session:
|
|
# Initialize repositories with session
|
|
await self._init_repositories(session)
|
|
|
|
members = await self.member_repo.get_tenant_members(
|
|
tenant_id, active_only=active_only, include_user_info=True
|
|
)
|
|
|
|
return [TenantMemberResponse.from_orm(member) for member in members]
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error getting team members",
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
error=str(e))
|
|
return []
|
|
|
|
async def update_member_role(
|
|
self,
|
|
tenant_id: str,
|
|
member_user_id: str,
|
|
new_role: str,
|
|
updated_by: str,
|
|
session: AsyncSession = None
|
|
) -> TenantMemberResponse:
|
|
"""Update team member role"""
|
|
|
|
try:
|
|
# Verify updater has admin access
|
|
access = await self.verify_user_access(updated_by, tenant_id)
|
|
if not access.has_access or access.role not in ["owner", "admin"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions to update member roles"
|
|
)
|
|
|
|
updated_member = await self.member_repo.update_member_role(
|
|
tenant_id, member_user_id, new_role, updated_by
|
|
)
|
|
|
|
if not updated_member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Member not found"
|
|
)
|
|
|
|
return TenantMemberResponse.from_orm(updated_member)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except (ValidationError, DuplicateRecordError) as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error updating member role",
|
|
tenant_id=tenant_id,
|
|
member_user_id=member_user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update member role"
|
|
)
|
|
|
|
async def remove_team_member(
|
|
self,
|
|
tenant_id: str,
|
|
member_user_id: str,
|
|
removed_by: str,
|
|
session: AsyncSession = None
|
|
) -> bool:
|
|
"""Remove team member from tenant"""
|
|
|
|
try:
|
|
# Verify remover has admin access
|
|
access = await self.verify_user_access(removed_by, tenant_id)
|
|
if not access.has_access or access.role not in ["owner", "admin"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions to remove team members"
|
|
)
|
|
|
|
removed_member = await self.member_repo.deactivate_membership(
|
|
tenant_id, member_user_id, removed_by
|
|
)
|
|
|
|
if not removed_member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Member not found"
|
|
)
|
|
|
|
return True
|
|
|
|
except HTTPException:
|
|
raise
|
|
except ValidationError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
logger.error("Error removing team member",
|
|
tenant_id=tenant_id,
|
|
member_user_id=member_user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to remove team member"
|
|
)
|
|
|
|
async def get_user_memberships(self, user_id: str) -> List[Dict[str, Any]]:
|
|
"""Get all tenant memberships for a user (for authentication service)"""
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Get all user memberships
|
|
memberships = await self.member_repo.get_user_memberships(user_id, active_only=False)
|
|
|
|
# Convert to response format
|
|
result = []
|
|
for membership in memberships:
|
|
result.append({
|
|
"id": str(membership.id),
|
|
"tenant_id": str(membership.tenant_id),
|
|
"user_id": str(membership.user_id),
|
|
"role": membership.role,
|
|
"is_active": membership.is_active,
|
|
"joined_at": membership.joined_at.isoformat() if membership.joined_at else None,
|
|
"invited_by": str(membership.invited_by) if membership.invited_by else None
|
|
})
|
|
|
|
logger.info("Retrieved user memberships",
|
|
user_id=user_id,
|
|
membership_count=len(result))
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to get user memberships",
|
|
user_id=user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to get user memberships"
|
|
)
|
|
|
|
async def update_model_status(
|
|
self,
|
|
tenant_id: str,
|
|
ml_model_trained: bool,
|
|
user_id: str,
|
|
last_training_date: datetime = None
|
|
) -> TenantResponse:
|
|
"""Update tenant model training status"""
|
|
|
|
try:
|
|
# Verify user has access
|
|
access = await self.verify_user_access(user_id, tenant_id)
|
|
if not access.has_access:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to tenant"
|
|
)
|
|
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
updated_tenant = await self.tenant_repo.update_tenant_model_status(
|
|
tenant_id, ml_model_trained, last_training_date
|
|
)
|
|
|
|
if not updated_tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
return TenantResponse.from_orm(updated_tenant)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error updating model status",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update model status"
|
|
)
|
|
|
|
async def get_tenant_statistics(self) -> Dict[str, Any]:
|
|
"""Get comprehensive tenant statistics"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
# Get tenant statistics
|
|
tenant_stats = await self.tenant_repo.get_tenant_statistics()
|
|
|
|
# Get subscription statistics
|
|
subscription_stats = await self.subscription_repo.get_subscription_statistics()
|
|
|
|
return {
|
|
"tenants": tenant_stats,
|
|
"subscriptions": subscription_stats
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting tenant statistics: {str(e)}")
|
|
return {
|
|
"tenants": {},
|
|
"subscriptions": {}
|
|
}
|
|
|
|
async def get_tenants_near_location(
|
|
self,
|
|
latitude: float,
|
|
longitude: float,
|
|
radius_km: float = 10.0,
|
|
limit: int = 50
|
|
) -> List[TenantResponse]:
|
|
"""Get tenants near a geographic location"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
tenants = await self.tenant_repo.get_tenants_by_location(
|
|
latitude, longitude, radius_km, limit
|
|
)
|
|
|
|
return [TenantResponse.from_orm(tenant) for tenant in tenants]
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting tenants by location",
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
error=str(e))
|
|
return []
|
|
|
|
async def deactivate_tenant(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
session: AsyncSession = None
|
|
) -> bool:
|
|
"""Deactivate a tenant (admin only)"""
|
|
|
|
try:
|
|
# Verify user is owner
|
|
access = await self.verify_user_access(user_id, tenant_id)
|
|
if not access.has_access or access.role != "owner":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only tenant owner can deactivate tenant"
|
|
)
|
|
|
|
deactivated_tenant = await self.tenant_repo.deactivate_tenant(tenant_id)
|
|
|
|
if not deactivated_tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
# Also suspend subscription
|
|
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
|
|
if subscription:
|
|
await self.subscription_repo.suspend_subscription(
|
|
str(subscription.id),
|
|
"Tenant deactivated"
|
|
)
|
|
|
|
logger.info("Tenant deactivated",
|
|
tenant_id=tenant_id,
|
|
deactivated_by=user_id)
|
|
|
|
return True
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error deactivating tenant",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to deactivate tenant"
|
|
)
|
|
|
|
async def activate_tenant(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
session: AsyncSession = None
|
|
) -> bool:
|
|
"""Activate a previously deactivated tenant (admin only)"""
|
|
|
|
try:
|
|
# Verify user is owner
|
|
access = await self.verify_user_access(user_id, tenant_id)
|
|
if not access.has_access or access.role != "owner":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only tenant owner can activate tenant"
|
|
)
|
|
|
|
activated_tenant = await self.tenant_repo.activate_tenant(tenant_id)
|
|
|
|
if not activated_tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
# Also reactivate subscription if exists
|
|
subscription = await self.subscription_repo.get_subscription_by_tenant(tenant_id)
|
|
if subscription and subscription.status == "suspended":
|
|
await self.subscription_repo.reactivate_subscription(str(subscription.id))
|
|
|
|
logger.info("Tenant activated",
|
|
tenant_id=tenant_id,
|
|
activated_by=user_id)
|
|
|
|
return True
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error activating tenant",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to activate tenant"
|
|
)
|
|
|
|
async def delete_tenant(
|
|
self,
|
|
tenant_id: str,
|
|
requesting_user_id: str = None,
|
|
skip_admin_check: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Permanently delete a tenant and all its associated data
|
|
|
|
Args:
|
|
tenant_id: The tenant to delete
|
|
requesting_user_id: The user requesting deletion (for permission check)
|
|
skip_admin_check: Skip the admin check (for internal service calls)
|
|
|
|
Returns:
|
|
Dict with deletion summary
|
|
"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Get tenant first to verify it exists
|
|
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
|
if not tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
# Permission check (unless internal service call)
|
|
if not skip_admin_check:
|
|
if not requesting_user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User ID required for deletion authorization"
|
|
)
|
|
|
|
access = await self.verify_user_access(requesting_user_id, tenant_id)
|
|
if not access.has_access or access.role not in ["owner", "admin"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only tenant owner or admin can delete tenant"
|
|
)
|
|
|
|
# Check if there are other admins (protection against accidental deletion)
|
|
admin_members = await self.member_repo.get_tenant_members(
|
|
tenant_id,
|
|
active_only=True,
|
|
role=None # Get all roles, we'll filter
|
|
)
|
|
|
|
admin_count = sum(1 for m in admin_members if m.role in ["owner", "admin"])
|
|
|
|
# Build deletion summary
|
|
deletion_summary = {
|
|
"tenant_id": tenant_id,
|
|
"tenant_name": tenant.name,
|
|
"admin_count": admin_count,
|
|
"total_members": len(admin_members),
|
|
"deleted_items": {},
|
|
"errors": []
|
|
}
|
|
|
|
# Cancel active subscriptions first
|
|
try:
|
|
subscription = await self.subscription_repo.get_active_subscription(tenant_id)
|
|
if subscription:
|
|
await self.subscription_repo.cancel_subscription(
|
|
str(subscription.id),
|
|
reason="Tenant deleted"
|
|
)
|
|
deletion_summary["deleted_items"]["subscriptions"] = 1
|
|
except Exception as e:
|
|
logger.warning("Failed to cancel subscription during tenant deletion",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
deletion_summary["errors"].append(f"Subscription cancellation: {str(e)}")
|
|
|
|
# Delete all tenant memberships (CASCADE will handle this, but we do it explicitly)
|
|
try:
|
|
deleted_members = 0
|
|
for member in admin_members:
|
|
try:
|
|
await self.member_repo.delete(str(member.id))
|
|
deleted_members += 1
|
|
except Exception as e:
|
|
logger.warning("Failed to delete membership",
|
|
membership_id=member.id,
|
|
error=str(e))
|
|
|
|
deletion_summary["deleted_items"]["memberships"] = deleted_members
|
|
except Exception as e:
|
|
logger.warning("Failed to delete memberships during tenant deletion",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
deletion_summary["errors"].append(f"Membership deletion: {str(e)}")
|
|
|
|
# Finally, delete the tenant itself (CASCADE should handle related records)
|
|
try:
|
|
await self.tenant_repo.delete(tenant_id)
|
|
deletion_summary["deleted_items"]["tenant"] = 1
|
|
except Exception as e:
|
|
logger.error("Failed to delete tenant record",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete tenant: {str(e)}"
|
|
)
|
|
|
|
# Publish deletion event for other services using unified messaging
|
|
try:
|
|
from shared.messaging import initialize_service_publisher, EVENT_TYPES
|
|
from app.core.config import settings
|
|
|
|
# Create a temporary publisher to send the event using the unified helper
|
|
temp_rabbitmq_client, temp_publisher = await initialize_service_publisher("tenant-service", settings.RABBITMQ_URL)
|
|
if temp_publisher:
|
|
try:
|
|
await temp_publisher.publish_business_event(
|
|
event_type=EVENT_TYPES.TENANT.TENANT_DELETED,
|
|
tenant_id=tenant_id,
|
|
data={
|
|
"tenant_id": tenant_id,
|
|
"tenant_name": tenant.name,
|
|
"deleted_at": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
)
|
|
finally:
|
|
if temp_rabbitmq_client:
|
|
await temp_rabbitmq_client.disconnect()
|
|
else:
|
|
logger.warning("Could not connect to RabbitMQ to publish tenant deletion event")
|
|
except Exception as e:
|
|
logger.warning("Failed to publish tenant deletion event",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
deletion_summary["errors"].append(f"Event publishing: {str(e)}")
|
|
|
|
logger.info("Tenant deleted successfully",
|
|
tenant_id=tenant_id,
|
|
tenant_name=tenant.name,
|
|
deleted_by=requesting_user_id,
|
|
summary=deletion_summary)
|
|
|
|
return deletion_summary
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error deleting tenant",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete tenant: {str(e)}"
|
|
)
|
|
|
|
async def delete_user_memberships(
|
|
self,
|
|
user_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Delete all tenant memberships for a user
|
|
Used when deleting a user from the auth service
|
|
|
|
Args:
|
|
user_id: The user whose memberships should be deleted
|
|
|
|
Returns:
|
|
Dict with deletion summary
|
|
"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Get all user memberships
|
|
memberships = await self.member_repo.get_user_memberships(user_id, active_only=False)
|
|
|
|
deleted_count = 0
|
|
errors = []
|
|
|
|
for membership in memberships:
|
|
try:
|
|
# Delete the membership
|
|
await self.member_repo.delete(str(membership.id))
|
|
deleted_count += 1
|
|
except Exception as e:
|
|
logger.warning("Failed to delete membership",
|
|
membership_id=membership.id,
|
|
user_id=user_id,
|
|
tenant_id=membership.tenant_id,
|
|
error=str(e))
|
|
errors.append(f"Membership {membership.id}: {str(e)}")
|
|
|
|
logger.info("User memberships deleted",
|
|
user_id=user_id,
|
|
total_memberships=len(memberships),
|
|
deleted_count=deleted_count,
|
|
errors=len(errors))
|
|
|
|
return {
|
|
"user_id": user_id,
|
|
"total_memberships": len(memberships),
|
|
"deleted_count": deleted_count,
|
|
"errors": errors
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Error deleting user memberships",
|
|
user_id=user_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete user memberships: {str(e)}"
|
|
)
|
|
|
|
async def transfer_tenant_ownership(
|
|
self,
|
|
tenant_id: str,
|
|
current_owner_id: str,
|
|
new_owner_id: str,
|
|
requesting_user_id: str = None
|
|
) -> TenantResponse:
|
|
"""
|
|
Transfer tenant ownership to another admin
|
|
|
|
Args:
|
|
tenant_id: The tenant whose ownership to transfer
|
|
current_owner_id: Current owner (for verification)
|
|
new_owner_id: New owner (must be an existing admin)
|
|
requesting_user_id: User requesting the transfer (for permission check)
|
|
|
|
Returns:
|
|
Updated tenant
|
|
"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
async with UnitOfWork(db_session) as uow:
|
|
# Register repositories
|
|
tenant_repo = uow.register_repository("tenants", TenantRepository, Tenant)
|
|
member_repo = uow.register_repository("members", TenantMemberRepository, TenantMember)
|
|
|
|
# Get tenant
|
|
tenant = await tenant_repo.get_by_id(tenant_id)
|
|
if not tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
# Verify current ownership
|
|
if str(tenant.owner_id) != current_owner_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Current owner ID does not match"
|
|
)
|
|
|
|
# Permission check (must be current owner or system)
|
|
if requesting_user_id and requesting_user_id != current_owner_id:
|
|
access = await self.verify_user_access(requesting_user_id, tenant_id)
|
|
if not access.has_access or access.role != "owner":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only current owner can transfer ownership"
|
|
)
|
|
|
|
# Verify new owner is an admin
|
|
new_owner_membership = await member_repo.get_membership(tenant_id, new_owner_id)
|
|
if not new_owner_membership or not new_owner_membership.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="New owner must be an active member of the tenant"
|
|
)
|
|
|
|
if new_owner_membership.role not in ["admin", "owner"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="New owner must be an admin"
|
|
)
|
|
|
|
# Update tenant owner
|
|
updated_tenant = await tenant_repo.update(tenant_id, {
|
|
"owner_id": new_owner_id
|
|
})
|
|
|
|
# Update memberships: current owner -> admin, new owner -> owner
|
|
current_owner_membership = await member_repo.get_membership(tenant_id, current_owner_id)
|
|
if current_owner_membership:
|
|
await member_repo.update_member_role(tenant_id, current_owner_id, "admin")
|
|
|
|
await member_repo.update_member_role(tenant_id, new_owner_id, "owner")
|
|
|
|
# Commit transaction
|
|
await uow.commit()
|
|
|
|
logger.info("Tenant ownership transferred",
|
|
tenant_id=tenant_id,
|
|
from_owner=current_owner_id,
|
|
to_owner=new_owner_id,
|
|
requested_by=requesting_user_id)
|
|
|
|
return TenantResponse.from_orm(updated_tenant)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Error transferring tenant ownership",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to transfer ownership: {str(e)}"
|
|
)
|
|
|
|
async def get_tenant_admins(
|
|
self,
|
|
tenant_id: str
|
|
) -> List[TenantMemberResponse]:
|
|
"""
|
|
Get all admins (owner + admins) for a tenant
|
|
Used by auth service to check for other admins before tenant deletion
|
|
|
|
Args:
|
|
tenant_id: The tenant to query
|
|
|
|
Returns:
|
|
List of admin members
|
|
"""
|
|
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Get all active members
|
|
all_members = await self.member_repo.get_tenant_members(
|
|
tenant_id,
|
|
active_only=True,
|
|
include_user_info=True
|
|
)
|
|
|
|
# Filter to just admins and owner
|
|
admin_members = [m for m in all_members if m.role in ["owner", "admin"]]
|
|
|
|
return [TenantMemberResponse.from_orm(m) for m in admin_members]
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting tenant admins",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
return []
|
|
|
|
|
|
# ========================================================================
|
|
# TENANT-INDEPENDENT SUBSCRIPTION METHODS (New Architecture)
|
|
# ========================================================================
|
|
|
|
async def link_subscription_to_tenant(
|
|
self,
|
|
tenant_id: str,
|
|
subscription_id: str,
|
|
user_id: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Link a pending subscription to a tenant
|
|
|
|
This completes the registration flow by associating the subscription
|
|
created during registration with the tenant created during onboarding
|
|
|
|
Args:
|
|
tenant_id: Tenant ID to link to
|
|
subscription_id: Subscription ID to link
|
|
user_id: User ID performing the linking (for validation)
|
|
|
|
Returns:
|
|
Dictionary with linking results
|
|
"""
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
async with UnitOfWork(db_session) as uow:
|
|
# Register repositories
|
|
subscription_repo = uow.register_repository(
|
|
"subscriptions", SubscriptionRepository, Subscription
|
|
)
|
|
tenant_repo = uow.register_repository(
|
|
"tenants", TenantRepository, Tenant
|
|
)
|
|
|
|
# Get the subscription
|
|
subscription = await subscription_repo.get_by_id(subscription_id)
|
|
|
|
if not subscription:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Subscription not found"
|
|
)
|
|
|
|
# Verify subscription is in pending_tenant_linking state
|
|
if subscription.tenant_linking_status != "pending":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Subscription is not in pending tenant linking state"
|
|
)
|
|
|
|
# Verify subscription belongs to this user
|
|
if subscription.user_id != user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Subscription does not belong to this user"
|
|
)
|
|
|
|
# Update subscription with tenant_id
|
|
update_data = {
|
|
"tenant_id": tenant_id,
|
|
"is_tenant_linked": True,
|
|
"tenant_linking_status": "completed",
|
|
"linked_at": datetime.now(timezone.utc)
|
|
}
|
|
|
|
await subscription_repo.update(subscription_id, update_data)
|
|
|
|
# Update tenant with subscription information including 3DS flags
|
|
tenant_update = {
|
|
"customer_id": subscription.customer_id,
|
|
"subscription_status": subscription.status,
|
|
"subscription_plan": subscription.plan,
|
|
"subscription_tier": subscription.plan,
|
|
"billing_cycle": subscription.billing_cycle,
|
|
"trial_period_days": subscription.trial_period_days,
|
|
"threeds_authentication_required": getattr(subscription, 'threeds_authentication_required', False),
|
|
"threeds_authentication_required_at": getattr(subscription, 'threeds_authentication_required_at', None),
|
|
"threeds_authentication_completed": getattr(subscription, 'threeds_authentication_completed', False),
|
|
"threeds_authentication_completed_at": getattr(subscription, 'threeds_authentication_completed_at', None),
|
|
"last_threeds_setup_intent_id": getattr(subscription, 'last_threeds_setup_intent_id', None),
|
|
"threeds_action_type": getattr(subscription, 'threeds_action_type', None)
|
|
}
|
|
|
|
await tenant_repo.update(tenant_id, tenant_update)
|
|
|
|
# Commit transaction
|
|
await uow.commit()
|
|
|
|
logger.info("Subscription successfully linked to tenant",
|
|
tenant_id=tenant_id,
|
|
subscription_id=subscription_id,
|
|
user_id=user_id)
|
|
return {
|
|
"success": True,
|
|
"tenant_id": tenant_id,
|
|
"subscription_id": subscription_id,
|
|
"status": "linked"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to link subscription to tenant",
|
|
error=str(e),
|
|
tenant_id=tenant_id,
|
|
subscription_id=subscription_id,
|
|
user_id=user_id)
|
|
raise
|
|
|
|
async def update_tenant_subscription_info(
|
|
self,
|
|
tenant_id: str,
|
|
update_data: Dict[str, Any]
|
|
) -> TenantResponse:
|
|
"""
|
|
Update tenant subscription-related information (plan, status, 3DS flags)
|
|
|
|
Args:
|
|
tenant_id: Tenant ID
|
|
update_data: Dictionary with fields to update
|
|
|
|
Returns:
|
|
Updated Tenant object
|
|
"""
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Filter allowed fields to prevent accidental overwrites of core tenant data
|
|
allowed_fields = {
|
|
'subscription_plan', 'subscription_status', 'subscription_tier',
|
|
'billing_cycle', 'trial_period_days', 'customer_id',
|
|
'threeds_authentication_required', 'threeds_authentication_required_at',
|
|
'threeds_authentication_completed', 'threeds_authentication_completed_at',
|
|
'last_threeds_setup_intent_id', 'threeds_action_type'
|
|
}
|
|
|
|
filtered_data = {k: v for k, v in update_data.items() if k in allowed_fields}
|
|
|
|
if not filtered_data:
|
|
logger.warning("No valid subscription info fields provided for update",
|
|
tenant_id=tenant_id)
|
|
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
|
return TenantResponse.from_orm(tenant)
|
|
|
|
updated_tenant = await self.tenant_repo.update(tenant_id, filtered_data)
|
|
|
|
if not updated_tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
|
|
logger.info("Tenant subscription info updated",
|
|
tenant_id=tenant_id,
|
|
updated_fields=list(filtered_data.keys()))
|
|
|
|
return TenantResponse.from_orm(updated_tenant)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update tenant subscription info",
|
|
tenant_id=tenant_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update tenant subscription info: {str(e)}"
|
|
)
|
|
|
|
async def get_tenant_by_customer_id(self, customer_id: str) -> Optional[Tenant]:
|
|
"""
|
|
Get tenant by Stripe customer ID
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
|
|
Returns:
|
|
Tenant object if found, None otherwise
|
|
"""
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Search for tenant with matching customer_id
|
|
tenant = await self.tenant_repo.get_by_customer_id(customer_id)
|
|
|
|
if tenant:
|
|
logger.info("Found tenant by customer_id",
|
|
customer_id=customer_id,
|
|
tenant_id=str(tenant.id))
|
|
return tenant
|
|
else:
|
|
logger.info("No tenant found for customer_id",
|
|
customer_id=customer_id)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting tenant by customer_id",
|
|
customer_id=customer_id,
|
|
error=str(e))
|
|
return None
|
|
|
|
async def get_subscriptions_by_customer_id(self, customer_id: str) -> List[Subscription]:
|
|
"""
|
|
Get subscriptions by Stripe customer ID
|
|
|
|
Args:
|
|
customer_id: Stripe customer ID
|
|
|
|
Returns:
|
|
List of Subscription objects
|
|
"""
|
|
try:
|
|
async with self.database_manager.get_session() as db_session:
|
|
await self._init_repositories(db_session)
|
|
|
|
# Search for subscriptions with matching customer_id
|
|
subscriptions = await self.subscription_repo.get_by_customer_id(customer_id)
|
|
|
|
logger.info("Found subscriptions by customer_id",
|
|
customer_id=customer_id,
|
|
count=len(subscriptions))
|
|
return subscriptions
|
|
|
|
except Exception as e:
|
|
logger.error("Error getting subscriptions by customer_id",
|
|
customer_id=customer_id,
|
|
error=str(e))
|
|
return []
|
|
|
|
# Legacy compatibility alias
|
|
TenantService = EnhancedTenantService
|