REFACTOR - Database logic

This commit is contained in:
Urtzi Alfaro
2025-08-08 09:08:41 +02:00
parent 0154365bfc
commit 488bb3ef93
113 changed files with 22842 additions and 6503 deletions

View File

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

View File

@@ -1,59 +1,75 @@
# services/tenant/app/api/tenants.py
"""
Tenant API endpoints
Enhanced Tenant API endpoints using repository pattern and dependency injection
"""
from fastapi import APIRouter, Depends, HTTPException, status, Path
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Dict, Any
import structlog
from uuid import UUID
from sqlalchemy import select, delete, func
from datetime import datetime
import uuid
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
from typing import List, Dict, Any, Optional
from uuid import UUID
from app.core.database import get_db
from app.services.messaging import publish_tenant_deleted_event
from app.schemas.tenants import (
BakeryRegistration, TenantResponse, TenantAccessResponse,
TenantUpdate, TenantMemberResponse
TenantUpdate, TenantMemberResponse, TenantSearchRequest
)
from app.services.tenant_service import TenantService
from app.services.tenant_service import EnhancedTenantService
from shared.auth.decorators import (
get_current_user_dep,
require_admin_role,
require_admin_role_dep
)
from shared.database.base import create_database_manager
from shared.monitoring.metrics import track_endpoint_metrics
logger = structlog.get_logger()
router = APIRouter()
# Dependency injection for enhanced tenant service
def get_enhanced_tenant_service():
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
return EnhancedTenantService(database_manager)
@router.post("/tenants/register", response_model=TenantResponse)
async def register_bakery(
async def register_bakery_enhanced(
bakery_data: BakeryRegistration,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Register a new bakery/tenant with enhanced validation and features"""
try:
result = await TenantService.create_bakery(bakery_data, current_user["user_id"], db)
logger.info(f"Bakery registered: {bakery_data.name} by {current_user['email']}")
result = await tenant_service.create_bakery(
bakery_data,
current_user["user_id"]
)
logger.info("Bakery registered successfully",
name=bakery_data.name,
owner_email=current_user.get('email'),
tenant_id=result.id)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Bakery registration failed: {e}")
logger.error("Bakery registration failed",
name=bakery_data.name,
owner_id=current_user["user_id"],
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Bakery registration failed"
)
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
async def verify_tenant_access(
user_id: str,
async def verify_tenant_access_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
user_id: str = Path(..., description="User ID")
):
"""Verify if user has access to tenant - Called by Gateway"""
"""Verify if user has access to tenant - Enhanced version with detailed permissions"""
# Check if this is a service request
if user_id in ["training-service", "data-service", "forecasting-service", "auth-service"]:
# Services have access to all tenants for their operations
@@ -64,32 +80,42 @@ async def verify_tenant_access(
)
try:
access_info = await TenantService.verify_user_access(user_id, tenant_id, db)
# Create tenant service directly
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
tenant_service = EnhancedTenantService(database_manager)
access_info = await tenant_service.verify_user_access(user_id, str(tenant_id))
return access_info
except Exception as e:
logger.error(f"Access verification failed: {e}")
logger.error("Access verification failed",
user_id=user_id,
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access verification failed"
)
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
async def get_tenant(
@track_endpoint_metrics("tenant_get")
async def get_tenant_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get tenant by ID with enhanced data and access control"""
# Verify user has access to tenant
access = await TenantService.verify_user_access(current_user["user_id"], tenant_id, db)
access = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
if not access.has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to tenant"
)
tenant = await TenantService.get_tenant_by_id(tenant_id, db)
tenant = await tenant_service.get_tenant_by_id(str(tenant_id))
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -98,481 +124,371 @@ async def get_tenant(
return tenant
@router.get("/tenants/subdomain/{subdomain}", response_model=TenantResponse)
@track_endpoint_metrics("tenant_get_by_subdomain")
async def get_tenant_by_subdomain_enhanced(
subdomain: str = Path(..., description="Tenant subdomain"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get tenant by subdomain with enhanced validation"""
tenant = await tenant_service.get_tenant_by_subdomain(subdomain)
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
# Verify user has access to this tenant
access = await tenant_service.verify_user_access(current_user["user_id"], tenant.id)
if not access.has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to tenant"
)
return tenant
@router.get("/tenants/user/{user_id}/owned", response_model=List[TenantResponse])
@track_endpoint_metrics("tenant_get_user_owned")
async def get_user_owned_tenants_enhanced(
user_id: str = Path(..., description="User ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all tenants owned by a user with enhanced data"""
# 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':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only access your own tenants"
)
tenants = await tenant_service.get_user_tenants(user_id)
return tenants
@router.get("/tenants/search", response_model=List[TenantResponse])
@track_endpoint_metrics("tenant_search")
async def search_tenants_enhanced(
search_term: str = Query(..., description="Search term"),
business_type: Optional[str] = Query(None, description="Business type filter"),
city: Optional[str] = Query(None, description="City filter"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum number of records to return"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Search tenants with advanced filters and pagination"""
tenants = await tenant_service.search_tenants(
search_term=search_term,
business_type=business_type,
city=city,
skip=skip,
limit=limit
)
return tenants
@router.get("/tenants/nearby", response_model=List[TenantResponse])
@track_endpoint_metrics("tenant_get_nearby")
async def get_nearby_tenants_enhanced(
latitude: float = Query(..., description="Latitude coordinate"),
longitude: float = Query(..., description="Longitude coordinate"),
radius_km: float = Query(10.0, ge=0.1, le=100.0, description="Search radius in kilometers"),
limit: int = Query(50, ge=1, le=100, description="Maximum number of results"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get tenants near a geographic location with enhanced geospatial search"""
tenants = await tenant_service.get_tenants_near_location(
latitude=latitude,
longitude=longitude,
radius_km=radius_km,
limit=limit
)
return tenants
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
async def update_tenant(
@track_endpoint_metrics("tenant_update")
async def update_tenant_enhanced(
update_data: TenantUpdate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Update tenant information with enhanced validation and permission checks"""
try:
result = await TenantService.update_tenant(tenant_id, update_data, current_user["user_id"], db)
result = await tenant_service.update_tenant(
str(tenant_id),
update_data,
current_user["user_id"]
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Tenant update failed: {e}")
logger.error("Tenant update failed",
tenant_id=str(tenant_id),
user_id=current_user["user_id"],
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Tenant update failed"
)
@router.put("/tenants/{tenant_id}/model-status")
@track_endpoint_metrics("tenant_update_model_status")
async def update_tenant_model_status_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
model_trained: bool = Query(..., description="Whether model is trained"),
last_training_date: Optional[datetime] = Query(None, description="Last training date"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Update tenant model training status with enhanced tracking"""
try:
result = await tenant_service.update_model_status(
str(tenant_id),
model_trained,
current_user["user_id"],
last_training_date
)
return result
except HTTPException:
raise
except Exception as e:
logger.error("Model status update failed",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update model status"
)
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
async def add_team_member(
@track_endpoint_metrics("tenant_add_member")
async def add_team_member_enhanced(
user_id: str,
role: str,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Add a team member to tenant with enhanced validation and role management"""
try:
result = await TenantService.add_team_member(
tenant_id, user_id, role, current_user["user_id"], db
result = await tenant_service.add_team_member(
str(tenant_id),
user_id,
role,
current_user["user_id"]
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Add team member failed: {e}")
logger.error("Add team member failed",
tenant_id=str(tenant_id),
user_id=user_id,
role=role,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add team member"
)
@router.delete("/tenants/{tenant_id}")
async def delete_tenant_complete(
tenant_id: str,
current_user = Depends(get_current_user_dep),
_admin_check = Depends(require_admin_role),
db: AsyncSession = Depends(get_db)
@router.get("/tenants/{tenant_id}/members", response_model=List[TenantMemberResponse])
@track_endpoint_metrics("tenant_get_members")
async def get_team_members_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
active_only: bool = Query(True, description="Only return active members"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Delete a tenant completely with all associated data.
**WARNING: This operation is irreversible!**
This endpoint:
1. Validates tenant exists and user has permissions
2. Deletes all tenant memberships
3. Deletes tenant subscription data
4. Deletes the tenant record
5. Publishes deletion event
Used by admin user deletion process when a tenant has no other admins.
"""
"""Get all team members for a tenant with enhanced filtering"""
try:
tenant_uuid = uuid.UUID(tenant_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID format"
members = await tenant_service.get_team_members(
str(tenant_id),
current_user["user_id"],
active_only=active_only
)
return members
except HTTPException:
raise
except Exception as e:
logger.error("Get team members failed",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get team members"
)
@router.put("/tenants/{tenant_id}/members/{member_user_id}/role", response_model=TenantMemberResponse)
@track_endpoint_metrics("tenant_update_member_role")
async def update_member_role_enhanced(
new_role: str,
tenant_id: UUID = Path(..., description="Tenant ID"),
member_user_id: str = Path(..., description="Member user ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Update team member role with enhanced permission validation"""
try:
from app.models.tenants import Tenant, TenantMember, Subscription
result = await tenant_service.update_member_role(
str(tenant_id),
member_user_id,
new_role,
current_user["user_id"]
)
return result
# Step 1: Verify tenant exists
tenant_query = select(Tenant).where(Tenant.id == tenant_uuid)
tenant_result = await db.execute(tenant_query)
tenant = tenant_result.scalar_one_or_none()
except HTTPException:
raise
except Exception as e:
logger.error("Update member role failed",
tenant_id=str(tenant_id),
member_user_id=member_user_id,
new_role=new_role,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update member role"
)
@router.delete("/tenants/{tenant_id}/members/{member_user_id}")
@track_endpoint_metrics("tenant_remove_member")
async def remove_team_member_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
member_user_id: str = Path(..., description="Member user ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Remove team member from tenant with enhanced validation"""
try:
success = await tenant_service.remove_team_member(
str(tenant_id),
member_user_id,
current_user["user_id"]
)
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
deletion_stats = {
"tenant_id": tenant_id,
"tenant_name": tenant.name,
"deleted_at": datetime.utcnow().isoformat(),
"memberships_deleted": 0,
"subscriptions_deleted": 0,
"errors": []
}
# Step 2: Delete all tenant memberships
try:
membership_count_query = select(func.count(TenantMember.id)).where(
TenantMember.tenant_id == tenant_uuid
)
membership_count_result = await db.execute(membership_count_query)
membership_count = membership_count_result.scalar()
membership_delete_query = delete(TenantMember).where(
TenantMember.tenant_id == tenant_uuid
)
await db.execute(membership_delete_query)
deletion_stats["memberships_deleted"] = membership_count
logger.info("Deleted tenant memberships",
tenant_id=tenant_id,
count=membership_count)
except Exception as e:
error_msg = f"Error deleting memberships: {str(e)}"
deletion_stats["errors"].append(error_msg)
logger.error(error_msg)
# Step 3: Delete subscription data
try:
subscription_count_query = select(func.count(Subscription.id)).where(
Subscription.tenant_id == tenant_uuid
)
subscription_count_result = await db.execute(subscription_count_query)
subscription_count = subscription_count_result.scalar()
subscription_delete_query = delete(Subscription).where(
Subscription.tenant_id == tenant_uuid
)
await db.execute(subscription_delete_query)
deletion_stats["subscriptions_deleted"] = subscription_count
logger.info("Deleted tenant subscriptions",
tenant_id=tenant_id,
count=subscription_count)
except Exception as e:
error_msg = f"Error deleting subscriptions: {str(e)}"
deletion_stats["errors"].append(error_msg)
logger.error(error_msg)
# Step 4: Delete the tenant record
try:
tenant_delete_query = delete(Tenant).where(Tenant.id == tenant_uuid)
tenant_result = await db.execute(tenant_delete_query)
if tenant_result.rowcount == 0:
raise Exception("Tenant record was not deleted")
await db.commit()
logger.info("Tenant deleted successfully",
tenant_id=tenant_id,
tenant_name=tenant.name)
except Exception as e:
await db.rollback()
error_msg = f"Error deleting tenant record: {str(e)}"
deletion_stats["errors"].append(error_msg)
logger.error(error_msg)
if success:
return {"success": True, "message": "Team member removed successfully"}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_msg
detail="Failed to remove team member"
)
# Step 5: Publish tenant deletion event
try:
await publish_tenant_deleted_event(tenant_id, deletion_stats)
except Exception as e:
logger.warning("Failed to publish tenant deletion event", error=str(e))
return {
"success": True,
"message": f"Tenant {tenant_id} deleted successfully",
"deletion_details": deletion_stats
}
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error deleting tenant",
tenant_id=tenant_id,
logger.error("Remove team member failed",
tenant_id=str(tenant_id),
member_user_id=member_user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete tenant: {str(e)}"
detail="Failed to remove team member"
)
@router.get("/tenants/user/{user_id}")
async def get_user_tenants(
user_id: str,
current_user = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
@router.post("/tenants/{tenant_id}/deactivate")
@track_endpoint_metrics("tenant_deactivate")
async def deactivate_tenant_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all tenant memberships for a user (admin only)"""
# Check if this is a service call or admin user
user_type = current_user.get('type', '')
user_role = current_user.get('role', '').lower()
service_name = current_user.get('service', '')
logger.info("The user_type and user_role", user_type=user_type, user_role=user_role)
# ✅ IMPROVED: Accept service tokens OR admin users
is_service_token = (user_type == 'service' or service_name in ['auth', 'admin'])
is_admin_user = (user_role == 'admin')
if not (is_service_token or is_admin_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role or service authentication required"
)
"""Deactivate a tenant (owner only) with enhanced validation"""
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
try:
from app.models.tenants import TenantMember, Tenant
# Get all memberships for the user
membership_query = select(TenantMember, Tenant).join(
Tenant, TenantMember.tenant_id == Tenant.id
).where(TenantMember.user_id == user_uuid)
result = await db.execute(membership_query)
memberships_data = result.all()
memberships = []
for membership, tenant in memberships_data:
memberships.append({
"user_id": str(membership.user_id),
"tenant_id": str(membership.tenant_id),
"tenant_name": tenant.name,
"role": membership.role,
"joined_at": membership.created_at.isoformat() if membership.created_at else None
})
return {
"user_id": user_id,
"total_tenants": len(memberships),
"memberships": memberships
}
except Exception as e:
logger.error("Failed to get user tenants", user_id=user_id, error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user tenants"
)
@router.get("/tenants/{tenant_id}/check-other-admins/{user_id}")
async def check_tenant_has_other_admins(
tenant_id: str,
user_id: str,
current_user = Depends(get_current_user_dep),
_admin_check = Depends(require_admin_role),
db: AsyncSession = Depends(get_db)
):
"""Check if tenant has other admin users besides the specified user"""
try:
tenant_uuid = uuid.UUID(tenant_id)
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid UUID format"
)
try:
from app.models.tenants import TenantMember
# Count admin/owner members excluding the specified user
admin_count_query = select(func.count(TenantMember.id)).where(
TenantMember.tenant_id == tenant_uuid,
TenantMember.role.in_(['admin', 'owner']),
TenantMember.user_id != user_uuid
success = await tenant_service.deactivate_tenant(
str(tenant_id),
current_user["user_id"]
)
result = await db.execute(admin_count_query)
admin_count = result.scalar()
return {
"tenant_id": tenant_id,
"excluded_user_id": user_id,
"has_other_admins": admin_count > 0,
"other_admin_count": admin_count
}
except Exception as e:
logger.error("Failed to check tenant admins",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to check tenant admins"
)
@router.post("/tenants/{tenant_id}/transfer-ownership")
async def transfer_tenant_ownership(
tenant_id: str,
transfer_data: dict, # {"current_owner_id": str, "new_owner_id": str}
current_user = Depends(get_current_user_dep),
_admin_check = Depends(require_admin_role),
db: AsyncSession = Depends(get_db)
):
"""Transfer tenant ownership from one user to another (admin only)"""
try:
tenant_uuid = uuid.UUID(tenant_id)
current_owner_id = uuid.UUID(transfer_data.get("current_owner_id"))
new_owner_id = uuid.UUID(transfer_data.get("new_owner_id"))
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid UUID format in request data"
)
try:
from app.models.tenants import TenantMember, Tenant
# Verify tenant exists
tenant_query = select(Tenant).where(Tenant.id == tenant_uuid)
tenant_result = await db.execute(tenant_query)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
if success:
return {"success": True, "message": "Tenant deactivated successfully"}
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to deactivate tenant"
)
# Get current owner membership
current_owner_query = select(TenantMember).where(
TenantMember.tenant_id == tenant_uuid,
TenantMember.user_id == current_owner_id,
TenantMember.role == 'owner'
)
current_owner_result = await db.execute(current_owner_query)
current_owner_membership = current_owner_result.scalar_one_or_none()
if not current_owner_membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Current owner membership not found"
)
# Get new owner membership (should be admin)
new_owner_query = select(TenantMember).where(
TenantMember.tenant_id == tenant_uuid,
TenantMember.user_id == new_owner_id
)
new_owner_result = await db.execute(new_owner_query)
new_owner_membership = new_owner_result.scalar_one_or_none()
if not new_owner_membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="New owner must be a 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 have admin or owner role"
)
# Perform the transfer
current_owner_membership.role = 'admin' # Demote current owner to admin
new_owner_membership.role = 'owner' # Promote new owner
current_owner_membership.updated_at = datetime.utcnow()
new_owner_membership.updated_at = datetime.utcnow()
await db.commit()
logger.info("Tenant ownership transferred",
tenant_id=tenant_id,
from_user=str(current_owner_id),
to_user=str(new_owner_id))
return {
"success": True,
"message": "Ownership transferred successfully",
"tenant_id": tenant_id,
"previous_owner": str(current_owner_id),
"new_owner": str(new_owner_id),
"transferred_at": datetime.utcnow().isoformat()
}
except HTTPException:
raise
except Exception as e:
await db.rollback()
logger.error("Failed to transfer tenant ownership",
tenant_id=tenant_id,
logger.error("Tenant deactivation failed",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to transfer tenant ownership"
detail="Failed to deactivate tenant"
)
@router.delete("/tenants/user/{user_id}/memberships")
async def delete_user_memberships(
user_id: str,
current_user = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
@router.post("/tenants/{tenant_id}/activate")
@track_endpoint_metrics("tenant_activate")
async def activate_tenant_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
# Check if this is a service call or admin user
user_type = current_user.get('type', '')
user_role = current_user.get('role', '').lower()
service_name = current_user.get('service', '')
logger.info("The user_type and user_role", user_type=user_type, user_role=user_role)
# ✅ IMPROVED: Accept service tokens OR admin users
is_service_token = (user_type == 'service' or service_name in ['auth', 'admin'])
is_admin_user = (user_role == 'admin')
if not (is_service_token or is_admin_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role or service authentication required"
)
"""Delete all tenant memberships for a user (admin only)"""
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID format"
)
"""Activate a previously deactivated tenant (owner only) with enhanced validation"""
try:
from app.models.tenants import TenantMember
# Count memberships before deletion
count_query = select(func.count(TenantMember.id)).where(
TenantMember.user_id == user_uuid
success = await tenant_service.activate_tenant(
str(tenant_id),
current_user["user_id"]
)
count_result = await db.execute(count_query)
membership_count = count_result.scalar()
# Delete all memberships
delete_query = delete(TenantMember).where(TenantMember.user_id == user_uuid)
delete_result = await db.execute(delete_query)
await db.commit()
logger.info("Deleted user memberships",
user_id=user_id,
memberships_deleted=delete_result.rowcount)
return {
"success": True,
"user_id": user_id,
"memberships_deleted": delete_result.rowcount,
"expected_count": membership_count,
"deleted_at": datetime.utcnow().isoformat()
}
if success:
return {"success": True, "message": "Tenant activated successfully"}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to activate tenant"
)
except HTTPException:
raise
except Exception as e:
await db.rollback()
logger.error("Failed to delete user memberships", user_id=user_id, error=str(e))
logger.error("Tenant activation failed",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete user memberships"
detail="Failed to activate tenant"
)
@router.get("/tenants/statistics", dependencies=[Depends(require_admin_role_dep)])
@track_endpoint_metrics("tenant_get_statistics")
async def get_tenant_statistics_enhanced(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get comprehensive tenant statistics (admin only) with enhanced analytics"""
try:
stats = await tenant_service.get_tenant_statistics()
return stats
except Exception as e:
logger.error("Get tenant statistics failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get tenant statistics"
)

View File

@@ -0,0 +1,16 @@
"""
Tenant Service Repositories
Repository implementations for tenant service
"""
from .base import TenantBaseRepository
from .tenant_repository import TenantRepository
from .tenant_member_repository import TenantMemberRepository
from .subscription_repository import SubscriptionRepository
__all__ = [
"TenantBaseRepository",
"TenantRepository",
"TenantMemberRepository",
"SubscriptionRepository"
]

View File

@@ -0,0 +1,234 @@
"""
Base Repository for Tenant Service
Service-specific repository base class with tenant management utilities
"""
from typing import Optional, List, Dict, Any, Type
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from datetime import datetime, timedelta
import structlog
import json
from shared.database.repository import BaseRepository
from shared.database.exceptions import DatabaseError
logger = structlog.get_logger()
class TenantBaseRepository(BaseRepository):
"""Base repository for tenant service with common tenant operations"""
def __init__(self, model: Type, session: AsyncSession, cache_ttl: Optional[int] = 600):
# Tenant data is relatively stable, medium cache time (10 minutes)
super().__init__(model, session, cache_ttl)
async def get_by_tenant_id(self, tenant_id: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by tenant ID"""
if hasattr(self.model, 'tenant_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"tenant_id": tenant_id},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def get_by_user_id(self, user_id: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by user ID (for cross-service references)"""
if hasattr(self.model, 'user_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"user_id": user_id},
order_by="created_at",
order_desc=True
)
elif hasattr(self.model, 'owner_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"owner_id": user_id},
order_by="created_at",
order_desc=True
)
return []
async def get_active_records(self, skip: int = 0, limit: int = 100) -> List:
"""Get active records (if model has is_active field)"""
if hasattr(self.model, 'is_active'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"is_active": True},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def deactivate_record(self, record_id: Any) -> Optional:
"""Deactivate a record instead of deleting it"""
if hasattr(self.model, 'is_active'):
return await self.update(record_id, {"is_active": False})
return await self.delete(record_id)
async def activate_record(self, record_id: Any) -> Optional:
"""Activate a record"""
if hasattr(self.model, 'is_active'):
return await self.update(record_id, {"is_active": True})
return await self.get_by_id(record_id)
async def cleanup_old_records(self, days_old: int = 365) -> int:
"""Clean up old tenant records (very conservative - 1 year)"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
table_name = self.model.__tablename__
# Only delete inactive records that are very old
conditions = [
"created_at < :cutoff_date"
]
if hasattr(self.model, 'is_active'):
conditions.append("is_active = false")
query_text = f"""
DELETE FROM {table_name}
WHERE {' AND '.join(conditions)}
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info(f"Cleaned up old {self.model.__name__} records",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old records",
model=self.model.__name__,
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")
async def get_statistics_by_tenant(self, tenant_id: str) -> Dict[str, Any]:
"""Get statistics for a tenant"""
try:
table_name = self.model.__tablename__
# Get basic counts
total_records = await self.count(filters={"tenant_id": tenant_id})
# Get active records if applicable
active_records = total_records
if hasattr(self.model, 'is_active'):
active_records = await self.count(filters={
"tenant_id": tenant_id,
"is_active": True
})
# Get recent activity (records in last 7 days)
seven_days_ago = datetime.utcnow() - timedelta(days=7)
recent_query = text(f"""
SELECT COUNT(*) as count
FROM {table_name}
WHERE tenant_id = :tenant_id
AND created_at >= :seven_days_ago
""")
result = await self.session.execute(recent_query, {
"tenant_id": tenant_id,
"seven_days_ago": seven_days_ago
})
recent_records = result.scalar() or 0
return {
"total_records": total_records,
"active_records": active_records,
"inactive_records": total_records - active_records,
"recent_records_7d": recent_records
}
except Exception as e:
logger.error("Failed to get tenant statistics",
model=self.model.__name__,
tenant_id=tenant_id,
error=str(e))
return {
"total_records": 0,
"active_records": 0,
"inactive_records": 0,
"recent_records_7d": 0
}
def _validate_tenant_data(self, data: Dict[str, Any], required_fields: List[str]) -> Dict[str, Any]:
"""Validate tenant-related data"""
errors = []
for field in required_fields:
if field not in data or not data[field]:
errors.append(f"Missing required field: {field}")
# Validate tenant_id format if present
if "tenant_id" in data and data["tenant_id"]:
tenant_id = data["tenant_id"]
if not isinstance(tenant_id, str) or len(tenant_id) < 1:
errors.append("Invalid tenant_id format")
# Validate user_id format if present
if "user_id" in data and data["user_id"]:
user_id = data["user_id"]
if not isinstance(user_id, str) or len(user_id) < 1:
errors.append("Invalid user_id format")
# Validate owner_id format if present
if "owner_id" in data and data["owner_id"]:
owner_id = data["owner_id"]
if not isinstance(owner_id, str) or len(owner_id) < 1:
errors.append("Invalid owner_id format")
# Validate email format if present
if "email" in data and data["email"]:
email = data["email"]
if "@" not in email or "." not in email.split("@")[-1]:
errors.append("Invalid email format")
# Validate phone format if present (basic validation)
if "phone" in data and data["phone"]:
phone = data["phone"]
if not isinstance(phone, str) or len(phone) < 9:
errors.append("Invalid phone format")
# Validate coordinates if present
if "latitude" in data and data["latitude"] is not None:
try:
lat = float(data["latitude"])
if lat < -90 or lat > 90:
errors.append("Invalid latitude - must be between -90 and 90")
except (ValueError, TypeError):
errors.append("Invalid latitude format")
if "longitude" in data and data["longitude"] is not None:
try:
lng = float(data["longitude"])
if lng < -180 or lng > 180:
errors.append("Invalid longitude - must be between -180 and 180")
except (ValueError, TypeError):
errors.append("Invalid longitude format")
# Validate JSON fields
json_fields = ["permissions"]
for field in json_fields:
if field in data and data[field]:
if isinstance(data[field], str):
try:
json.loads(data[field])
except json.JSONDecodeError:
errors.append(f"Invalid JSON format in {field}")
return {
"is_valid": len(errors) == 0,
"errors": errors
}

View File

@@ -0,0 +1,420 @@
"""
Subscription Repository
Repository for subscription operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime, timedelta
import structlog
import json
from .base import TenantBaseRepository
from app.models.tenants import Subscription
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class SubscriptionRepository(TenantBaseRepository):
"""Repository for subscription operations"""
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 600):
# Subscriptions are relatively stable, medium cache time (10 minutes)
super().__init__(model_class, session, cache_ttl)
async def create_subscription(self, subscription_data: Dict[str, Any]) -> Subscription:
"""Create a new subscription with validation"""
try:
# Validate subscription data
validation_result = self._validate_tenant_data(
subscription_data,
["tenant_id", "plan"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid subscription data: {validation_result['errors']}")
# Check for existing active subscription
existing_subscription = await self.get_active_subscription(
subscription_data["tenant_id"]
)
if existing_subscription:
raise DuplicateRecordError(f"Tenant already has an active subscription")
# Set default values based on plan
plan_config = self._get_plan_configuration(subscription_data["plan"])
# Set defaults from plan config
for key, value in plan_config.items():
if key not in subscription_data:
subscription_data[key] = value
# Set default subscription values
if "status" not in subscription_data:
subscription_data["status"] = "active"
if "billing_cycle" not in subscription_data:
subscription_data["billing_cycle"] = "monthly"
if "next_billing_date" not in subscription_data:
# Set next billing date based on cycle
if subscription_data["billing_cycle"] == "yearly":
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=365)
else:
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=30)
# Create subscription
subscription = await self.create(subscription_data)
logger.info("Subscription created successfully",
subscription_id=subscription.id,
tenant_id=subscription.tenant_id,
plan=subscription.plan,
monthly_price=subscription.monthly_price)
return subscription
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create subscription",
tenant_id=subscription_data.get("tenant_id"),
plan=subscription_data.get("plan"),
error=str(e))
raise DatabaseError(f"Failed to create subscription: {str(e)}")
async def get_active_subscription(self, tenant_id: str) -> Optional[Subscription]:
"""Get active subscription for tenant"""
try:
subscriptions = await self.get_multi(
filters={
"tenant_id": tenant_id,
"status": "active"
},
limit=1,
order_by="created_at",
order_desc=True
)
return subscriptions[0] if subscriptions else None
except Exception as e:
logger.error("Failed to get active subscription",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get subscription: {str(e)}")
async def get_tenant_subscriptions(
self,
tenant_id: str,
include_inactive: bool = False
) -> List[Subscription]:
"""Get all subscriptions for a tenant"""
try:
filters = {"tenant_id": tenant_id}
if not include_inactive:
filters["status"] = "active"
return await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenant subscriptions",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get subscriptions: {str(e)}")
async def update_subscription_plan(
self,
subscription_id: str,
new_plan: str
) -> Optional[Subscription]:
"""Update subscription plan and pricing"""
try:
valid_plans = ["basic", "professional", "enterprise"]
if new_plan not in valid_plans:
raise ValidationError(f"Invalid plan. Must be one of: {valid_plans}")
# Get new plan configuration
plan_config = self._get_plan_configuration(new_plan)
# Update subscription with new plan details
update_data = {
"plan": new_plan,
"monthly_price": plan_config["monthly_price"],
"max_users": plan_config["max_users"],
"max_locations": plan_config["max_locations"],
"max_products": plan_config["max_products"],
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription plan updated",
subscription_id=subscription_id,
new_plan=new_plan,
new_price=plan_config["monthly_price"])
return updated_subscription
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update subscription plan",
subscription_id=subscription_id,
new_plan=new_plan,
error=str(e))
raise DatabaseError(f"Failed to update plan: {str(e)}")
async def cancel_subscription(
self,
subscription_id: str,
reason: str = None
) -> Optional[Subscription]:
"""Cancel a subscription"""
try:
update_data = {
"status": "cancelled",
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription cancelled",
subscription_id=subscription_id,
reason=reason)
return updated_subscription
except Exception as e:
logger.error("Failed to cancel subscription",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to cancel subscription: {str(e)}")
async def suspend_subscription(
self,
subscription_id: str,
reason: str = None
) -> Optional[Subscription]:
"""Suspend a subscription"""
try:
update_data = {
"status": "suspended",
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription suspended",
subscription_id=subscription_id,
reason=reason)
return updated_subscription
except Exception as e:
logger.error("Failed to suspend subscription",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to suspend subscription: {str(e)}")
async def reactivate_subscription(
self,
subscription_id: str
) -> Optional[Subscription]:
"""Reactivate a cancelled or suspended subscription"""
try:
# Reset billing date when reactivating
next_billing_date = datetime.utcnow() + timedelta(days=30)
update_data = {
"status": "active",
"next_billing_date": next_billing_date,
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription reactivated",
subscription_id=subscription_id,
next_billing_date=next_billing_date)
return updated_subscription
except Exception as e:
logger.error("Failed to reactivate subscription",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to reactivate subscription: {str(e)}")
async def get_subscriptions_due_for_billing(
self,
days_ahead: int = 3
) -> List[Subscription]:
"""Get subscriptions that need billing in the next N days"""
try:
cutoff_date = datetime.utcnow() + timedelta(days=days_ahead)
query_text = """
SELECT * FROM subscriptions
WHERE status = 'active'
AND next_billing_date <= :cutoff_date
ORDER BY next_billing_date ASC
"""
result = await self.session.execute(text(query_text), {
"cutoff_date": cutoff_date
})
subscriptions = []
for row in result.fetchall():
record_dict = dict(row._mapping)
subscription = self.model(**record_dict)
subscriptions.append(subscription)
return subscriptions
except Exception as e:
logger.error("Failed to get subscriptions due for billing",
days_ahead=days_ahead,
error=str(e))
return []
async def update_billing_date(
self,
subscription_id: str,
next_billing_date: datetime
) -> Optional[Subscription]:
"""Update next billing date for subscription"""
try:
updated_subscription = await self.update(subscription_id, {
"next_billing_date": next_billing_date,
"updated_at": datetime.utcnow()
})
logger.info("Subscription billing date updated",
subscription_id=subscription_id,
next_billing_date=next_billing_date)
return updated_subscription
except Exception as e:
logger.error("Failed to update billing date",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to update billing date: {str(e)}")
async def get_subscription_statistics(self) -> Dict[str, Any]:
"""Get subscription statistics"""
try:
# Get counts by plan
plan_query = text("""
SELECT plan, COUNT(*) as count
FROM subscriptions
WHERE status = 'active'
GROUP BY plan
ORDER BY count DESC
""")
result = await self.session.execute(plan_query)
subscriptions_by_plan = {row.plan: row.count for row in result.fetchall()}
# Get counts by status
status_query = text("""
SELECT status, COUNT(*) as count
FROM subscriptions
GROUP BY status
ORDER BY count DESC
""")
result = await self.session.execute(status_query)
subscriptions_by_status = {row.status: row.count for row in result.fetchall()}
# Get revenue statistics
revenue_query = text("""
SELECT
SUM(monthly_price) as total_monthly_revenue,
AVG(monthly_price) as avg_monthly_price,
COUNT(*) as total_active_subscriptions
FROM subscriptions
WHERE status = 'active'
""")
revenue_result = await self.session.execute(revenue_query)
revenue_row = revenue_result.fetchone()
# Get upcoming billing count
thirty_days_ahead = datetime.utcnow() + timedelta(days=30)
upcoming_billing = len(await self.get_subscriptions_due_for_billing(30))
return {
"subscriptions_by_plan": subscriptions_by_plan,
"subscriptions_by_status": subscriptions_by_status,
"total_monthly_revenue": float(revenue_row.total_monthly_revenue or 0),
"avg_monthly_price": float(revenue_row.avg_monthly_price or 0),
"total_active_subscriptions": int(revenue_row.total_active_subscriptions or 0),
"upcoming_billing_30d": upcoming_billing
}
except Exception as e:
logger.error("Failed to get subscription statistics", error=str(e))
return {
"subscriptions_by_plan": {},
"subscriptions_by_status": {},
"total_monthly_revenue": 0.0,
"avg_monthly_price": 0.0,
"total_active_subscriptions": 0,
"upcoming_billing_30d": 0
}
async def cleanup_old_subscriptions(self, days_old: int = 730) -> int:
"""Clean up very old cancelled subscriptions (2 years)"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM subscriptions
WHERE status IN ('cancelled', 'suspended')
AND updated_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up old subscriptions",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old subscriptions",
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")
def _get_plan_configuration(self, plan: str) -> Dict[str, Any]:
"""Get configuration for a subscription plan"""
plan_configs = {
"basic": {
"monthly_price": 29.99,
"max_users": 2,
"max_locations": 1,
"max_products": 50
},
"professional": {
"monthly_price": 79.99,
"max_users": 10,
"max_locations": 3,
"max_products": 200
},
"enterprise": {
"monthly_price": 199.99,
"max_users": 50,
"max_locations": 10,
"max_products": 1000
}
}
return plan_configs.get(plan, plan_configs["basic"])

View File

@@ -0,0 +1,447 @@
"""
Tenant Member Repository
Repository for tenant membership operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime, timedelta
import structlog
import json
from .base import TenantBaseRepository
from app.models.tenants import TenantMember
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class TenantMemberRepository(TenantBaseRepository):
"""Repository for tenant member operations"""
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 300):
# Member data changes more frequently, shorter cache time (5 minutes)
super().__init__(model_class, session, cache_ttl)
async def create_membership(self, membership_data: Dict[str, Any]) -> TenantMember:
"""Create a new tenant membership with validation"""
try:
# Validate membership data
validation_result = self._validate_tenant_data(
membership_data,
["tenant_id", "user_id", "role"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid membership data: {validation_result['errors']}")
# Check for existing membership
existing_membership = await self.get_membership(
membership_data["tenant_id"],
membership_data["user_id"]
)
if existing_membership and existing_membership.is_active:
raise DuplicateRecordError(f"User is already an active member of this tenant")
# Set default values
if "is_active" not in membership_data:
membership_data["is_active"] = True
if "joined_at" not in membership_data:
membership_data["joined_at"] = datetime.utcnow()
# Set permissions based on role
if "permissions" not in membership_data:
membership_data["permissions"] = self._get_default_permissions(
membership_data["role"]
)
# If reactivating existing membership
if existing_membership and not existing_membership.is_active:
# Update existing membership
update_data = {
key: value for key, value in membership_data.items()
if key not in ["tenant_id", "user_id"]
}
membership = await self.update(existing_membership.id, update_data)
else:
# Create new membership
membership = await self.create(membership_data)
logger.info("Tenant membership created",
membership_id=membership.id,
tenant_id=membership.tenant_id,
user_id=membership.user_id,
role=membership.role)
return membership
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create membership",
tenant_id=membership_data.get("tenant_id"),
user_id=membership_data.get("user_id"),
error=str(e))
raise DatabaseError(f"Failed to create membership: {str(e)}")
async def get_membership(self, tenant_id: str, user_id: str) -> Optional[TenantMember]:
"""Get specific membership by tenant and user"""
try:
memberships = await self.get_multi(
filters={
"tenant_id": tenant_id,
"user_id": user_id
},
limit=1,
order_by="created_at",
order_desc=True
)
return memberships[0] if memberships else None
except Exception as e:
logger.error("Failed to get membership",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get membership: {str(e)}")
async def get_tenant_members(
self,
tenant_id: str,
active_only: bool = True,
role: str = None
) -> List[TenantMember]:
"""Get all members of a tenant"""
try:
filters = {"tenant_id": tenant_id}
if active_only:
filters["is_active"] = True
if role:
filters["role"] = role
return await self.get_multi(
filters=filters,
order_by="joined_at",
order_desc=False
)
except Exception as e:
logger.error("Failed to get tenant members",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get members: {str(e)}")
async def get_user_memberships(
self,
user_id: str,
active_only: bool = True
) -> List[TenantMember]:
"""Get all tenants a user is a member of"""
try:
filters = {"user_id": user_id}
if active_only:
filters["is_active"] = True
return await self.get_multi(
filters=filters,
order_by="joined_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get user memberships",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get memberships: {str(e)}")
async def verify_user_access(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""Verify if user has access to tenant and return access details"""
try:
membership = await self.get_membership(tenant_id, user_id)
if not membership or not membership.is_active:
return {
"has_access": False,
"role": "none",
"permissions": []
}
# Parse permissions
permissions = []
if membership.permissions:
try:
permissions = json.loads(membership.permissions)
except json.JSONDecodeError:
logger.warning("Invalid permissions JSON for membership",
membership_id=membership.id)
permissions = self._get_default_permissions(membership.role)
return {
"has_access": True,
"role": membership.role,
"permissions": permissions,
"membership_id": str(membership.id),
"joined_at": membership.joined_at.isoformat() if membership.joined_at else None
}
except Exception as e:
logger.error("Failed to verify user access",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return {
"has_access": False,
"role": "none",
"permissions": []
}
async def update_member_role(
self,
tenant_id: str,
user_id: str,
new_role: str,
updated_by: str = None
) -> Optional[TenantMember]:
"""Update member role and permissions"""
try:
valid_roles = ["owner", "admin", "member", "viewer"]
if new_role not in valid_roles:
raise ValidationError(f"Invalid role. Must be one of: {valid_roles}")
membership = await self.get_membership(tenant_id, user_id)
if not membership:
raise ValidationError("Membership not found")
# Get new permissions based on role
new_permissions = self._get_default_permissions(new_role)
updated_membership = await self.update(membership.id, {
"role": new_role,
"permissions": json.dumps(new_permissions)
})
logger.info("Member role updated",
membership_id=membership.id,
tenant_id=tenant_id,
user_id=user_id,
old_role=membership.role,
new_role=new_role,
updated_by=updated_by)
return updated_membership
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update member role",
tenant_id=tenant_id,
user_id=user_id,
new_role=new_role,
error=str(e))
raise DatabaseError(f"Failed to update role: {str(e)}")
async def deactivate_membership(
self,
tenant_id: str,
user_id: str,
deactivated_by: str = None
) -> Optional[TenantMember]:
"""Deactivate a membership (remove user from tenant)"""
try:
membership = await self.get_membership(tenant_id, user_id)
if not membership:
raise ValidationError("Membership not found")
# Don't allow deactivating the owner
if membership.role == "owner":
raise ValidationError("Cannot deactivate the owner membership")
updated_membership = await self.update(membership.id, {
"is_active": False
})
logger.info("Membership deactivated",
membership_id=membership.id,
tenant_id=tenant_id,
user_id=user_id,
deactivated_by=deactivated_by)
return updated_membership
except ValidationError:
raise
except Exception as e:
logger.error("Failed to deactivate membership",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to deactivate membership: {str(e)}")
async def reactivate_membership(
self,
tenant_id: str,
user_id: str,
reactivated_by: str = None
) -> Optional[TenantMember]:
"""Reactivate a deactivated membership"""
try:
membership = await self.get_membership(tenant_id, user_id)
if not membership:
raise ValidationError("Membership not found")
updated_membership = await self.update(membership.id, {
"is_active": True,
"joined_at": datetime.utcnow() # Update join date
})
logger.info("Membership reactivated",
membership_id=membership.id,
tenant_id=tenant_id,
user_id=user_id,
reactivated_by=reactivated_by)
return updated_membership
except ValidationError:
raise
except Exception as e:
logger.error("Failed to reactivate membership",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to reactivate membership: {str(e)}")
async def get_membership_statistics(self, tenant_id: str) -> Dict[str, Any]:
"""Get membership statistics for a tenant"""
try:
# Get counts by role
role_query = text("""
SELECT role, COUNT(*) as count
FROM tenant_members
WHERE tenant_id = :tenant_id AND is_active = true
GROUP BY role
ORDER BY count DESC
""")
result = await self.session.execute(role_query, {"tenant_id": tenant_id})
members_by_role = {row.role: row.count for row in result.fetchall()}
# Get basic counts
total_members = await self.count(filters={"tenant_id": tenant_id})
active_members = await self.count(filters={
"tenant_id": tenant_id,
"is_active": True
})
# Get recent activity (members joined in last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
recent_joins = len(await self.get_multi(
filters={
"tenant_id": tenant_id,
"is_active": True
},
limit=1000 # High limit to get accurate count
))
# Filter for recent joins (manual filtering since we can't use date range in filters easily)
recent_members = 0
all_active_members = await self.get_tenant_members(tenant_id, active_only=True)
for member in all_active_members:
if member.joined_at and member.joined_at >= thirty_days_ago:
recent_members += 1
return {
"total_members": total_members,
"active_members": active_members,
"inactive_members": total_members - active_members,
"members_by_role": members_by_role,
"recent_joins_30d": recent_members
}
except Exception as e:
logger.error("Failed to get membership statistics",
tenant_id=tenant_id,
error=str(e))
return {
"total_members": 0,
"active_members": 0,
"inactive_members": 0,
"members_by_role": {},
"recent_joins_30d": 0
}
def _get_default_permissions(self, role: str) -> str:
"""Get default permissions JSON string for a role"""
permission_map = {
"owner": ["read", "write", "admin", "delete"],
"admin": ["read", "write", "admin"],
"member": ["read", "write"],
"viewer": ["read"]
}
permissions = permission_map.get(role, ["read"])
return json.dumps(permissions)
async def bulk_update_permissions(
self,
tenant_id: str,
role_permissions: Dict[str, List[str]]
) -> int:
"""Bulk update permissions for all members of specific roles"""
try:
updated_count = 0
for role, permissions in role_permissions.items():
members = await self.get_tenant_members(
tenant_id, active_only=True, role=role
)
for member in members:
await self.update(member.id, {
"permissions": json.dumps(permissions)
})
updated_count += 1
logger.info("Bulk updated member permissions",
tenant_id=tenant_id,
updated_count=updated_count,
roles=list(role_permissions.keys()))
return updated_count
except Exception as e:
logger.error("Failed to bulk update permissions",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Bulk permission update failed: {str(e)}")
async def cleanup_inactive_memberships(self, days_old: int = 180) -> int:
"""Clean up old inactive memberships"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM tenant_members
WHERE is_active = false
AND created_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up inactive memberships",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup inactive memberships",
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")

View File

@@ -0,0 +1,410 @@
"""
Tenant Repository
Repository for tenant operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime, timedelta
import structlog
import uuid
from .base import TenantBaseRepository
from app.models.tenants import Tenant
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class TenantRepository(TenantBaseRepository):
"""Repository for tenant operations"""
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 600):
# Tenants are relatively stable, longer cache time (10 minutes)
super().__init__(model_class, session, cache_ttl)
async def create_tenant(self, tenant_data: Dict[str, Any]) -> Tenant:
"""Create a new tenant with validation"""
try:
# Validate tenant data
validation_result = self._validate_tenant_data(
tenant_data,
["name", "address", "postal_code", "owner_id"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid tenant data: {validation_result['errors']}")
# Generate subdomain if not provided
if "subdomain" not in tenant_data or not tenant_data["subdomain"]:
subdomain = await self._generate_unique_subdomain(tenant_data["name"])
tenant_data["subdomain"] = subdomain
else:
# Check if provided subdomain is unique
existing_tenant = await self.get_by_subdomain(tenant_data["subdomain"])
if existing_tenant:
raise DuplicateRecordError(f"Subdomain {tenant_data['subdomain']} already exists")
# Set default values
if "business_type" not in tenant_data:
tenant_data["business_type"] = "bakery"
if "city" not in tenant_data:
tenant_data["city"] = "Madrid"
if "is_active" not in tenant_data:
tenant_data["is_active"] = True
if "subscription_tier" not in tenant_data:
tenant_data["subscription_tier"] = "basic"
if "model_trained" not in tenant_data:
tenant_data["model_trained"] = False
# Create tenant
tenant = await self.create(tenant_data)
logger.info("Tenant created successfully",
tenant_id=tenant.id,
name=tenant.name,
subdomain=tenant.subdomain,
owner_id=tenant.owner_id)
return tenant
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create tenant",
name=tenant_data.get("name"),
error=str(e))
raise DatabaseError(f"Failed to create tenant: {str(e)}")
async def get_by_subdomain(self, subdomain: str) -> Optional[Tenant]:
"""Get tenant by subdomain"""
try:
return await self.get_by_field("subdomain", subdomain)
except Exception as e:
logger.error("Failed to get tenant by subdomain",
subdomain=subdomain,
error=str(e))
raise DatabaseError(f"Failed to get tenant: {str(e)}")
async def get_tenants_by_owner(self, owner_id: str) -> List[Tenant]:
"""Get all tenants owned by a user"""
try:
return await self.get_multi(
filters={"owner_id": owner_id, "is_active": True},
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenants by owner",
owner_id=owner_id,
error=str(e))
raise DatabaseError(f"Failed to get tenants: {str(e)}")
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> List[Tenant]:
"""Get all active tenants"""
return await self.get_active_records(skip=skip, limit=limit)
async def search_tenants(
self,
search_term: str,
business_type: str = None,
city: str = None,
skip: int = 0,
limit: int = 50
) -> List[Tenant]:
"""Search tenants by name, address, or other criteria"""
try:
# Build search conditions
conditions = ["is_active = true"]
params = {"skip": skip, "limit": limit}
# Add text search
conditions.append("(LOWER(name) LIKE LOWER(:search_term) OR LOWER(address) LIKE LOWER(:search_term))")
params["search_term"] = f"%{search_term}%"
# Add business type filter
if business_type:
conditions.append("business_type = :business_type")
params["business_type"] = business_type
# Add city filter
if city:
conditions.append("LOWER(city) = LOWER(:city)")
params["city"] = city
query_text = f"""
SELECT * FROM tenants
WHERE {' AND '.join(conditions)}
ORDER BY name ASC
LIMIT :limit OFFSET :skip
"""
result = await self.session.execute(text(query_text), params)
tenants = []
for row in result.fetchall():
record_dict = dict(row._mapping)
tenant = self.model(**record_dict)
tenants.append(tenant)
return tenants
except Exception as e:
logger.error("Failed to search tenants",
search_term=search_term,
error=str(e))
return []
async def update_tenant_model_status(
self,
tenant_id: str,
model_trained: bool,
last_training_date: datetime = None
) -> Optional[Tenant]:
"""Update tenant model training status"""
try:
update_data = {
"model_trained": model_trained,
"updated_at": datetime.utcnow()
}
if last_training_date:
update_data["last_training_date"] = last_training_date
elif model_trained:
update_data["last_training_date"] = datetime.utcnow()
updated_tenant = await self.update(tenant_id, update_data)
logger.info("Tenant model status updated",
tenant_id=tenant_id,
model_trained=model_trained,
last_training_date=last_training_date)
return updated_tenant
except Exception as e:
logger.error("Failed to update tenant model status",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update model status: {str(e)}")
async def update_subscription_tier(
self,
tenant_id: str,
subscription_tier: str
) -> Optional[Tenant]:
"""Update tenant subscription tier"""
try:
valid_tiers = ["basic", "professional", "enterprise"]
if subscription_tier not in valid_tiers:
raise ValidationError(f"Invalid subscription tier. Must be one of: {valid_tiers}")
updated_tenant = await self.update(tenant_id, {
"subscription_tier": subscription_tier,
"updated_at": datetime.utcnow()
})
logger.info("Tenant subscription tier updated",
tenant_id=tenant_id,
subscription_tier=subscription_tier)
return updated_tenant
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update subscription tier",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update subscription: {str(e)}")
async def get_tenants_by_location(
self,
latitude: float,
longitude: float,
radius_km: float = 10.0,
limit: int = 50
) -> List[Tenant]:
"""Get tenants within a geographic radius"""
try:
# Using Haversine formula for distance calculation
query_text = """
SELECT *,
(6371 * acos(
cos(radians(:latitude)) *
cos(radians(latitude)) *
cos(radians(longitude) - radians(:longitude)) +
sin(radians(:latitude)) *
sin(radians(latitude))
)) AS distance_km
FROM tenants
WHERE is_active = true
AND latitude IS NOT NULL
AND longitude IS NOT NULL
HAVING distance_km <= :radius_km
ORDER BY distance_km ASC
LIMIT :limit
"""
result = await self.session.execute(text(query_text), {
"latitude": latitude,
"longitude": longitude,
"radius_km": radius_km,
"limit": limit
})
tenants = []
for row in result.fetchall():
# Create tenant object (excluding the calculated distance_km field)
record_dict = dict(row._mapping)
record_dict.pop("distance_km", None) # Remove calculated field
tenant = self.model(**record_dict)
tenants.append(tenant)
return tenants
except Exception as e:
logger.error("Failed to get tenants by location",
latitude=latitude,
longitude=longitude,
radius_km=radius_km,
error=str(e))
return []
async def get_tenant_statistics(self) -> Dict[str, Any]:
"""Get global tenant statistics"""
try:
# Get basic counts
total_tenants = await self.count()
active_tenants = await self.count(filters={"is_active": True})
# Get tenants by business type
business_type_query = text("""
SELECT business_type, COUNT(*) as count
FROM tenants
WHERE is_active = true
GROUP BY business_type
ORDER BY count DESC
""")
result = await self.session.execute(business_type_query)
business_type_stats = {row.business_type: row.count for row in result.fetchall()}
# Get tenants by subscription tier
tier_query = text("""
SELECT subscription_tier, COUNT(*) as count
FROM tenants
WHERE is_active = true
GROUP BY subscription_tier
ORDER BY count DESC
""")
tier_result = await self.session.execute(tier_query)
tier_stats = {row.subscription_tier: row.count for row in tier_result.fetchall()}
# Get model training statistics
model_query = text("""
SELECT
COUNT(CASE WHEN model_trained = true THEN 1 END) as trained_count,
COUNT(CASE WHEN model_trained = false THEN 1 END) as untrained_count,
AVG(EXTRACT(EPOCH FROM (NOW() - last_training_date))/86400) as avg_days_since_training
FROM tenants
WHERE is_active = true
""")
model_result = await self.session.execute(model_query)
model_row = model_result.fetchone()
# Get recent registrations (last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
recent_registrations = await self.count(filters={
"created_at": f">= '{thirty_days_ago.isoformat()}'"
})
return {
"total_tenants": total_tenants,
"active_tenants": active_tenants,
"inactive_tenants": total_tenants - active_tenants,
"tenants_by_business_type": business_type_stats,
"tenants_by_subscription": tier_stats,
"model_training": {
"trained_tenants": int(model_row.trained_count or 0),
"untrained_tenants": int(model_row.untrained_count or 0),
"avg_days_since_training": float(model_row.avg_days_since_training or 0)
} if model_row else {
"trained_tenants": 0,
"untrained_tenants": 0,
"avg_days_since_training": 0.0
},
"recent_registrations_30d": recent_registrations
}
except Exception as e:
logger.error("Failed to get tenant statistics", error=str(e))
return {
"total_tenants": 0,
"active_tenants": 0,
"inactive_tenants": 0,
"tenants_by_business_type": {},
"tenants_by_subscription": {},
"model_training": {
"trained_tenants": 0,
"untrained_tenants": 0,
"avg_days_since_training": 0.0
},
"recent_registrations_30d": 0
}
async def _generate_unique_subdomain(self, name: str) -> str:
"""Generate a unique subdomain from tenant name"""
try:
# Clean the name to create a subdomain
subdomain = name.lower().replace(' ', '-')
# Remove accents
subdomain = subdomain.replace('á', 'a').replace('é', 'e').replace('í', 'i').replace('ó', 'o').replace('ú', 'u')
subdomain = subdomain.replace('ñ', 'n')
# Keep only alphanumeric and hyphens
subdomain = ''.join(c for c in subdomain if c.isalnum() or c == '-')
# Remove multiple consecutive hyphens
while '--' in subdomain:
subdomain = subdomain.replace('--', '-')
# Remove leading/trailing hyphens
subdomain = subdomain.strip('-')
# Ensure minimum length
if len(subdomain) < 3:
subdomain = f"tenant-{subdomain}"
# Check if subdomain exists
existing_tenant = await self.get_by_subdomain(subdomain)
if not existing_tenant:
return subdomain
# If it exists, add a unique suffix
counter = 1
while True:
candidate = f"{subdomain}-{counter}"
existing_tenant = await self.get_by_subdomain(candidate)
if not existing_tenant:
return candidate
counter += 1
# Prevent infinite loop
if counter > 9999:
return f"{subdomain}-{uuid.uuid4().hex[:6]}"
except Exception as e:
logger.error("Failed to generate unique subdomain",
name=name,
error=str(e))
# Fallback to UUID-based subdomain
return f"tenant-{uuid.uuid4().hex[:8]}"
async def deactivate_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""Deactivate a tenant"""
return await self.deactivate_record(tenant_id)
async def activate_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""Activate a tenant"""
return await self.activate_record(tenant_id)

View File

@@ -143,4 +143,13 @@ class TenantStatsResponse(BaseModel):
"""Convert UUID objects to strings for JSON serialization"""
if isinstance(v, UUID):
return str(v)
return v
return v
class TenantSearchRequest(BaseModel):
"""Tenant search request schema"""
query: Optional[str] = None
business_type: Optional[str] = None
city: Optional[str] = None
status: Optional[str] = None
limit: int = Field(default=50, ge=1, le=100)
offset: int = Field(default=0, ge=0)

View File

@@ -0,0 +1,14 @@
"""
Tenant Service Layer
Business logic services for tenant operations
"""
from .tenant_service import TenantService, EnhancedTenantService
from .messaging import publish_tenant_created, publish_member_added
__all__ = [
"TenantService",
"EnhancedTenantService",
"publish_tenant_created",
"publish_member_added"
]

View File

@@ -1,269 +1,671 @@
# services/tenant/app/services/tenant_service.py
"""
Tenant service business logic
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 sqlalchemy import select, update, and_
from fastapi import HTTPException, status
import uuid
import json
from app.models.tenants import Tenant, TenantMember
from app.schemas.tenants import BakeryRegistration, TenantResponse, TenantAccessResponse, TenantUpdate, TenantMemberResponse
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 app.services.messaging import publish_tenant_created, publish_member_added
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
from shared.database.base import create_database_manager
from shared.database.unit_of_work import UnitOfWork
logger = structlog.get_logger()
class TenantService:
"""Tenant management business logic"""
class EnhancedTenantService:
"""Enhanced tenant management business logic using repository pattern with dependency injection"""
@staticmethod
async def create_bakery(bakery_data: BakeryRegistration, owner_id: str, db: AsyncSession) -> TenantResponse:
"""Create a new bakery/tenant"""
def __init__(self, database_manager=None):
self.database_manager = database_manager or create_database_manager()
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:
# Generate subdomain if not provided
subdomain = bakery_data.name.lower().replace(' ', '-').replace('á', 'a').replace('é', 'e').replace('í', 'i').replace('ó', 'o').replace('ú', 'u')
subdomain = ''.join(c for c in subdomain if c.isalnum() or c == '-')
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)
# 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": getattr(bakery_data, 'latitude', None),
"longitude": getattr(bakery_data, 'longitude', None),
"is_active": True
}
# Check if subdomain already exists
result = await db.execute(
select(Tenant).where(Tenant.subdomain == subdomain)
)
if result.scalar_one_or_none():
subdomain = f"{subdomain}-{uuid.uuid4().hex[:6]}"
# Create tenant
tenant = Tenant(
name=bakery_data.name,
subdomain=subdomain,
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,
is_active=True
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
# Create tenant using repository
tenant = await tenant_repo.create_tenant(tenant_data)
# Create owner membership
owner_membership = TenantMember(
tenant_id=tenant.id,
user_id=owner_id,
role="owner",
permissions=json.dumps(["read", "write", "admin", "delete"]),
is_active=True,
joined_at=datetime.now(timezone.utc)
)
membership_data = {
"tenant_id": str(tenant.id),
"user_id": owner_id,
"role": "owner",
"is_active": True
}
db.add(owner_membership)
await db.commit()
owner_membership = await member_repo.create_membership(membership_data)
# Create basic subscription
subscription_data = {
"tenant_id": str(tenant.id),
"plan": "basic",
"status": "active"
}
subscription = await subscription_repo.create_subscription(subscription_data)
# Commit the transaction
await uow.commit()
# Publish event
await publish_tenant_created(str(tenant.id), owner_id, bakery_data.name)
try:
await publish_tenant_created(str(tenant.id), owner_id, bakery_data.name)
except Exception as e:
logger.warning("Failed to publish tenant created event", error=str(e))
logger.info(f"Bakery created: {bakery_data.name} (ID: {tenant.id})")
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:
await db.rollback()
logger.error(f"Error creating bakery: {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"
)
@staticmethod
async def verify_user_access(user_id: str, tenant_id: str, db: AsyncSession) -> TenantAccessResponse:
"""Verify if user has access to tenant"""
async def verify_user_access(
self,
user_id: str,
tenant_id: str
) -> TenantAccessResponse:
"""Verify if user has access to tenant with enhanced permissions"""
try:
# Check if user is tenant member
result = await db.execute(
select(TenantMember).where(
and_(
TenantMember.user_id == user_id,
TenantMember.tenant_id == tenant_id,
TenantMember.is_active == True
)
)
)
membership = result.scalar_one_or_none()
if not membership:
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=False,
role="none",
permissions=[]
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")
)
# Parse permissions
permissions = []
if membership.permissions:
try:
permissions = json.loads(membership.permissions)
except:
permissions = []
return TenantAccessResponse(
has_access=True,
role=membership.role,
permissions=permissions
)
except Exception as e:
logger.error(f"Error verifying user access: {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=[]
)
@staticmethod
async def get_tenant_by_id(tenant_id: str, db: AsyncSession) -> Optional[TenantResponse]:
"""Get tenant by ID"""
async def get_tenant_by_id(self, tenant_id: str) -> Optional[TenantResponse]:
"""Get tenant by ID with enhanced data"""
try:
result = await db.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
tenant = result.scalar_one_or_none()
if tenant:
return TenantResponse.from_orm(tenant)
return None
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(f"Error getting tenant: {e}")
logger.error("Error getting tenant",
tenant_id=tenant_id,
error=str(e))
return None
@staticmethod
async def update_tenant(tenant_id: str, update_data: TenantUpdate, user_id: str, db: AsyncSession) -> TenantResponse:
"""Update tenant information"""
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, owner_id: str) -> List[TenantResponse]:
"""Get all tenants owned by a user"""
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_owner(owner_id)
return [TenantResponse.from_orm(tenant) for tenant in tenants]
except Exception as e:
logger.error("Error getting user tenants",
owner_id=owner_id,
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 TenantService.verify_user_access(user_id, tenant_id, db)
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
# Update tenant using repository
update_values = update_data.dict(exclude_unset=True)
if update_values:
update_values["updated_at"] = datetime.now(timezone.utc)
updated_tenant = await self.tenant_repo.update(tenant_id, update_values)
await db.execute(
update(Tenant)
.where(Tenant.id == tenant_id)
.values(**update_values)
)
await db.commit()
# Get updated tenant
result = await db.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
tenant = result.scalar_one()
logger.info(f"Tenant updated: {tenant.name} (ID: {tenant_id})")
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
tenant = await self.tenant_repo.get_by_id(tenant_id)
return TenantResponse.from_orm(tenant)
except HTTPException:
raise
except Exception as e:
await db.rollback()
logger.error(f"Error updating tenant: {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"
)
@staticmethod
async def add_team_member(tenant_id: str, user_id: str, role: str, invited_by: str, db: AsyncSession) -> TenantMemberResponse:
"""Add a team member to 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 TenantService.verify_user_access(invited_by, tenant_id, db)
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"
)
# Check if user is already a member
result = await db.execute(
select(TenantMember).where(
and_(
TenantMember.tenant_id == tenant_id,
TenantMember.user_id == user_id
)
)
)
# Create membership using repository
membership_data = {
"tenant_id": tenant_id,
"user_id": user_id,
"role": role,
"invited_by": invited_by,
"is_active": True
}
existing_member = result.scalar_one_or_none()
if existing_member:
if existing_member.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is already a member of this tenant"
)
else:
# Reactivate existing membership
existing_member.is_active = True
existing_member.role = role
existing_member.joined_at = datetime.now(timezone.utc)
await db.commit()
return TenantMemberResponse.from_orm(existing_member)
# Create new membership
permissions = ["read"]
if role in ["admin", "owner"]:
permissions.extend(["write", "admin"])
if role == "owner":
permissions.append("delete")
member = TenantMember(
tenant_id=tenant_id,
user_id=user_id,
role=role,
permissions=json.dumps(permissions),
invited_by=invited_by,
is_active=True,
joined_at=datetime.now(timezone.utc)
)
db.add(member)
await db.commit()
await db.refresh(member)
member = await self.member_repo.create_membership(membership_data)
# Publish event
await publish_member_added(tenant_id, user_id, role)
try:
await publish_member_added(tenant_id, user_id, role)
except Exception as e:
logger.warning("Failed to publish member added event", error=str(e))
logger.info(f"Team member added: {user_id} to tenant {tenant_id} as {role}")
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:
await db.rollback()
logger.error(f"Error adding team member: {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"""
try:
# Verify user has access to tenant
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"
)
members = await self.member_repo.get_tenant_members(
tenant_id, active_only=active_only
)
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 update_model_status(
self,
tenant_id: str,
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, 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("Error getting tenant statistics", error=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"
)
# Legacy compatibility alias
TenantService = EnhancedTenantService