REFACTOR - Database logic
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Tenant API Package
|
||||
API endpoints for tenant management
|
||||
"""
|
||||
|
||||
from . import tenants
|
||||
|
||||
__all__ = ["tenants"]
|
||||
@@ -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"
|
||||
)
|
||||
16
services/tenant/app/repositories/__init__.py
Normal file
16
services/tenant/app/repositories/__init__.py
Normal 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"
|
||||
]
|
||||
234
services/tenant/app/repositories/base.py
Normal file
234
services/tenant/app/repositories/base.py
Normal 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
|
||||
}
|
||||
420
services/tenant/app/repositories/subscription_repository.py
Normal file
420
services/tenant/app/repositories/subscription_repository.py
Normal 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"])
|
||||
447
services/tenant/app/repositories/tenant_member_repository.py
Normal file
447
services/tenant/app/repositories/tenant_member_repository.py
Normal 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)}")
|
||||
410
services/tenant/app/repositories/tenant_repository.py
Normal file
410
services/tenant/app/repositories/tenant_repository.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user