529 lines
21 KiB
Python
529 lines
21 KiB
Python
from datetime import date, datetime, timezone
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_, cast, String, asc, desc
|
|
from fastapi import HTTPException, status
|
|
from app.models.auth.tenant_model import Tenant
|
|
from app.models.auth.tenant_module_model import TenantModule
|
|
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
|
from app.models.auth.plan_access_model import PlanAccess
|
|
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
|
from app.models.auth.module_access_model import ModuleAccess
|
|
from app.models.auth.role_model import Role
|
|
from app.models.auth.role_module_access_model import RoleModuleAccess
|
|
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
|
|
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate
|
|
from app.services.auth.role_service import RoleService
|
|
import uuid
|
|
from typing import List, Optional
|
|
from app.services.auth.event_service import EventService
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class TenantService:
|
|
STATUS_ACTIVE = "ACTIVE"
|
|
STATUS_INACTIVE = "INACTIVE"
|
|
STATUS_EXPIRED = "EXPIRED"
|
|
|
|
@staticmethod
|
|
def _today() -> date:
|
|
return datetime.now(timezone.utc).date()
|
|
|
|
@staticmethod
|
|
def _normalize_status(status_value: Optional[str], is_active: bool) -> str:
|
|
normalized = (status_value or "").strip().upper()
|
|
if normalized:
|
|
return normalized
|
|
return TenantService.STATUS_ACTIVE if is_active else TenantService.STATUS_INACTIVE
|
|
|
|
@staticmethod
|
|
def _resolve_lifecycle(
|
|
*,
|
|
start_date: Optional[date],
|
|
end_date: Optional[date],
|
|
status_value: Optional[str],
|
|
is_active: bool,
|
|
) -> tuple[str, bool]:
|
|
if start_date and end_date and end_date < start_date:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="end_date must be greater than or equal to start_date",
|
|
)
|
|
|
|
normalized_status = TenantService._normalize_status(status_value, is_active)
|
|
if end_date and end_date < TenantService._today():
|
|
return TenantService.STATUS_EXPIRED, False
|
|
if normalized_status in {
|
|
TenantService.STATUS_INACTIVE,
|
|
TenantService.STATUS_EXPIRED,
|
|
}:
|
|
return normalized_status, False
|
|
return normalized_status, True
|
|
|
|
@staticmethod
|
|
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
|
|
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Tenant name already exists"
|
|
)
|
|
|
|
existing = db.query(Tenant).filter(Tenant.tenant_domain == tenant_data.tenant_domain).first()
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Tenant domain already exists"
|
|
)
|
|
|
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == tenant_data.plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid plan_id provided"
|
|
)
|
|
|
|
provisioning_id = str(uuid.uuid4())
|
|
|
|
try:
|
|
# 1. Create Tenant
|
|
tenant = Tenant(
|
|
tenant_name=tenant_data.tenant_name,
|
|
tenant_domain=tenant_data.tenant_domain,
|
|
tenant_logo_url=tenant_data.tenant_logo_url,
|
|
plan_id=tenant_data.plan_id,
|
|
start_date=tenant_data.start_date,
|
|
end_date=tenant_data.end_date,
|
|
)
|
|
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
|
|
start_date=tenant.start_date,
|
|
end_date=tenant.end_date,
|
|
status_value=tenant_data.status,
|
|
is_active=tenant.is_active,
|
|
)
|
|
db.add(tenant)
|
|
db.flush()
|
|
|
|
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
|
|
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
|
|
|
|
distinct_module_ids = []
|
|
if mod_access_ids:
|
|
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
|
|
distinct_module_ids = [m[0] for m in modules_query]
|
|
|
|
module_env_map = {}
|
|
if tenant_data.module_environments:
|
|
for me in tenant_data.module_environments:
|
|
module_env_map[me.module_id] = me.environment_slug
|
|
|
|
event_targets = []
|
|
for mod_id in distinct_module_ids:
|
|
env_slug = module_env_map.get(mod_id, tenant_data.default_environment_slug)
|
|
tm = TenantModule(
|
|
tenant_id=tenant.id,
|
|
module_id=mod_id,
|
|
assigned_environment_slug=env_slug,
|
|
is_active=True
|
|
)
|
|
db.add(tm)
|
|
|
|
event_targets.append({
|
|
"module_id": str(mod_id),
|
|
"environment_slug": env_slug
|
|
})
|
|
|
|
db.flush()
|
|
|
|
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
|
|
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
|
|
|
|
role_create_data = RoleCreate(
|
|
role_name="Primary Admin",
|
|
tenant_id=tenant.id,
|
|
is_default=True,
|
|
access_ids=all_access_ids
|
|
)
|
|
role = RoleService.create_role(db, role_create_data, emit_events=False)
|
|
|
|
role_module_perms = (
|
|
db.query(RoleModuleAccess, ModuleAccess)
|
|
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
|
.filter(RoleModuleAccess.role_id == role.id)
|
|
.all()
|
|
)
|
|
|
|
role_follow_up = None
|
|
if role_module_perms:
|
|
module_map = {}
|
|
for rma, ma in role_module_perms:
|
|
mid = str(ma.module_id)
|
|
if mid not in module_map:
|
|
module_map[mid] = []
|
|
module_map[mid].append(ma.access_code)
|
|
|
|
role_targets = []
|
|
for target in event_targets:
|
|
mid = target["module_id"]
|
|
if mid in module_map:
|
|
role_targets.append({
|
|
"module_id": mid,
|
|
"environment_slug": target["environment_slug"],
|
|
"permissions": module_map[mid]
|
|
})
|
|
|
|
if role_targets:
|
|
role_follow_up = {
|
|
"event_type": "ROLE_PROVISION_REQUESTED",
|
|
"tenant_id": str(tenant.id),
|
|
"payload": {
|
|
"role_id": str(role.id),
|
|
"role_name": role.role_name,
|
|
"tenant_id": str(tenant.id),
|
|
"provisioning_id": str(uuid.uuid4()),
|
|
"targets": role_targets
|
|
}
|
|
}
|
|
|
|
if event_targets:
|
|
logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.")
|
|
|
|
payload = {
|
|
"tenant_id": str(tenant.id),
|
|
"tenant_name": tenant.tenant_name,
|
|
"tenant_domain": tenant.tenant_domain,
|
|
"tenant_logo_url": tenant.tenant_logo_url,
|
|
"provisioning_id": provisioning_id,
|
|
"targets": event_targets
|
|
}
|
|
|
|
EventService.emit_event(
|
|
db,
|
|
event_type="TENANT_PROVISION_REQUESTED",
|
|
payload=payload,
|
|
tenant_id=tenant.id,
|
|
follow_up_event=role_follow_up
|
|
)
|
|
logger.info(f"Event TENANT_PROVISION_REQUESTED emitted to outbox{' (with ROLE_PROVISION_REQUESTED follow-up)' if role_follow_up else ''}.")
|
|
|
|
db.commit()
|
|
db.refresh(tenant)
|
|
|
|
return tenant
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise e
|
|
|
|
@staticmethod
|
|
def get_all_tenants(db: Session):
|
|
return db.query(Tenant).all()
|
|
|
|
@staticmethod
|
|
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
|
|
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
|
if not tenant:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tenant not found"
|
|
)
|
|
return tenant
|
|
|
|
@staticmethod
|
|
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate) -> Tenant:
|
|
tenant = TenantService.get_tenant_by_id(db, tenant_id)
|
|
|
|
update_dict = tenant_data.model_dump(exclude_unset=True)
|
|
|
|
should_emit_update = False
|
|
should_emit_status = False
|
|
|
|
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
|
|
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Tenant name already exists")
|
|
should_emit_update = True
|
|
|
|
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
|
|
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Tenant domain already exists")
|
|
should_emit_update = True
|
|
|
|
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
|
|
should_emit_update = True
|
|
|
|
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
|
|
should_emit_status = True
|
|
|
|
if "plan_id" in update_dict and update_dict["plan_id"] != tenant.plan_id:
|
|
new_plan_id = update_dict["plan_id"]
|
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == new_plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=400, detail="Invalid plan_id")
|
|
|
|
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
|
|
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
|
|
|
|
new_modules_set = set()
|
|
if mod_access_ids:
|
|
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
|
|
new_modules_set = {m[0] for m in modules_query}
|
|
|
|
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
|
|
current_map = {tm.module_id: tm for tm in current_modules}
|
|
|
|
event_targets = []
|
|
provisioning_id = str(uuid.uuid4())
|
|
|
|
default_env = "prod"
|
|
|
|
for mod_id in new_modules_set:
|
|
if mod_id in current_map:
|
|
tm = current_map[mod_id]
|
|
if not tm.is_active:
|
|
tm.is_active = True
|
|
event_targets.append({
|
|
"module_id": str(mod_id),
|
|
"environment_slug": tm.assigned_environment_slug or default_env
|
|
})
|
|
else:
|
|
tm = TenantModule(
|
|
tenant_id=tenant.id,
|
|
module_id=mod_id,
|
|
assigned_environment_slug=default_env,
|
|
is_active=True
|
|
)
|
|
db.add(tm)
|
|
event_targets.append({
|
|
"module_id": str(mod_id),
|
|
"environment_slug": default_env
|
|
})
|
|
|
|
for mod_id, tm in current_map.items():
|
|
if mod_id not in new_modules_set and tm.is_active:
|
|
tm.is_active = False
|
|
|
|
db.flush()
|
|
|
|
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
|
|
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
|
|
|
|
default_role = db.query(Role).filter(Role.tenant_id == tenant.id, Role.is_default == True).first()
|
|
if default_role:
|
|
role_update_data = RoleUpdate(access_ids=all_access_ids)
|
|
RoleService.update_role(db, default_role.id, role_update_data, is_superadmin=True)
|
|
|
|
role_follow_up = None
|
|
if event_targets and default_role:
|
|
role_module_perms = (
|
|
db.query(RoleModuleAccess, ModuleAccess)
|
|
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
|
.filter(RoleModuleAccess.role_id == default_role.id)
|
|
.all()
|
|
)
|
|
|
|
module_map = {}
|
|
for rma, ma in role_module_perms:
|
|
mid = str(ma.module_id)
|
|
if mid not in module_map:
|
|
module_map[mid] = []
|
|
module_map[mid].append(ma.access_code)
|
|
|
|
role_targets = []
|
|
for target in event_targets:
|
|
mid = target["module_id"]
|
|
if mid in module_map:
|
|
role_targets.append({
|
|
"module_id": mid,
|
|
"environment_slug": target["environment_slug"],
|
|
"permissions": module_map[mid]
|
|
})
|
|
|
|
if role_targets:
|
|
role_follow_up = {
|
|
"event_type": "ROLE_PROVISION_REQUESTED",
|
|
"tenant_id": str(tenant.id),
|
|
"payload": {
|
|
"role_id": str(default_role.id),
|
|
"role_name": default_role.role_name,
|
|
"tenant_id": str(tenant.id),
|
|
"provisioning_id": str(uuid.uuid4()),
|
|
"targets": role_targets
|
|
}
|
|
}
|
|
|
|
if event_targets:
|
|
payload = {
|
|
"tenant_id": str(tenant.id),
|
|
"tenant_name": tenant.tenant_name,
|
|
"provisioning_id": provisioning_id,
|
|
"targets": event_targets
|
|
}
|
|
EventService.emit_event(
|
|
db,
|
|
event_type="TENANT_PROVISION_REQUESTED",
|
|
payload=payload,
|
|
tenant_id=tenant.id,
|
|
follow_up_event=role_follow_up
|
|
)
|
|
|
|
for key, value in update_dict.items():
|
|
setattr(tenant, key, value)
|
|
|
|
if any(key in update_dict for key in ("start_date", "end_date", "status", "is_active")):
|
|
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
|
|
start_date=tenant.start_date,
|
|
end_date=tenant.end_date,
|
|
status_value=update_dict.get("status"),
|
|
is_active=tenant.is_active,
|
|
)
|
|
should_emit_status = True
|
|
|
|
if should_emit_update or should_emit_status:
|
|
active_modules = db.query(TenantModule).filter(
|
|
TenantModule.tenant_id == tenant.id,
|
|
TenantModule.is_active == True
|
|
).all()
|
|
|
|
broadcast_targets = [
|
|
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
|
|
for tm in active_modules
|
|
]
|
|
|
|
if broadcast_targets:
|
|
|
|
if should_emit_update:
|
|
payload = {
|
|
"tenant_id": str(tenant.id),
|
|
"tenant_name": tenant.tenant_name,
|
|
"tenant_domain": tenant.tenant_domain,
|
|
"tenant_logo_url": tenant.tenant_logo_url,
|
|
"targets": broadcast_targets
|
|
}
|
|
EventService.emit_event(
|
|
db,
|
|
event_type="TENANT_UPDATED",
|
|
payload=payload,
|
|
tenant_id=tenant.id
|
|
)
|
|
|
|
if should_emit_status:
|
|
payload = {
|
|
"tenant_id": str(tenant.id),
|
|
"is_active": tenant.is_active,
|
|
"status": "ACTIVE" if tenant.is_active else "INACTIVE",
|
|
"targets": broadcast_targets
|
|
}
|
|
EventService.emit_event(
|
|
db,
|
|
event_type="TENANT_STATUS_CHANGED",
|
|
payload=payload,
|
|
tenant_id=tenant.id
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(tenant)
|
|
return tenant
|
|
|
|
@staticmethod
|
|
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
|
tenant = TenantService.get_tenant_by_id(db, tenant_id)
|
|
|
|
active_modules = db.query(TenantModule).filter(
|
|
TenantModule.tenant_id == tenant.id,
|
|
TenantModule.is_active == True
|
|
).all()
|
|
|
|
if active_modules:
|
|
broadcast_targets = [
|
|
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
|
|
for tm in active_modules
|
|
]
|
|
|
|
if broadcast_targets:
|
|
|
|
payload = {
|
|
"tenant_id": str(tenant.id),
|
|
"tenant_name": tenant.tenant_name,
|
|
"targets": broadcast_targets
|
|
}
|
|
|
|
EventService.emit_event(
|
|
db,
|
|
event_type="TENANT_DEPROVISION_REQUESTED",
|
|
payload=payload,
|
|
tenant_id=tenant.id
|
|
)
|
|
|
|
db.delete(tenant)
|
|
db.commit()
|
|
return {"message": "Tenant deleted successfully"}
|
|
|
|
@staticmethod
|
|
def get_tenants_paginated(
|
|
db: Session,
|
|
page: int = 1,
|
|
page_size: int = 10,
|
|
search: Optional[str] = None,
|
|
is_active: Optional[bool] = None,
|
|
filter_tenant_names: Optional[List[str]] = None,
|
|
filter_tenant_domains: Optional[List[str]] = None,
|
|
filter_plan_ids: Optional[List[uuid.UUID]] = None,
|
|
statuses: Optional[List[bool]] = None,
|
|
sort_by: Optional[str] = None,
|
|
sort_order: Optional[str] = None,
|
|
) -> TenantPaginatedResponse:
|
|
|
|
query = db.query(Tenant)
|
|
|
|
if filter_tenant_names:
|
|
query = query.filter(Tenant.tenant_name.in_(filter_tenant_names))
|
|
|
|
if filter_tenant_domains:
|
|
query = query.filter(Tenant.tenant_domain.in_(filter_tenant_domains))
|
|
|
|
if filter_plan_ids:
|
|
query = query.filter(Tenant.plan_id.in_(filter_plan_ids))
|
|
|
|
if search and search.strip():
|
|
search_term = search.strip()
|
|
query = query.filter(
|
|
or_(
|
|
Tenant.tenant_name.ilike(f"%{search_term}%"),
|
|
Tenant.tenant_domain.ilike(f"%{search_term}%"),
|
|
cast(Tenant.id, String).ilike(f"%{search_term}%"),
|
|
)
|
|
)
|
|
|
|
if is_active is not None:
|
|
query = query.filter(Tenant.is_active == is_active)
|
|
|
|
if statuses is not None and len(statuses) > 0:
|
|
query = query.filter(Tenant.is_active.in_(statuses))
|
|
|
|
sort_column_map = {
|
|
"name": Tenant.tenant_name,
|
|
"domain": Tenant.tenant_domain,
|
|
"status": Tenant.is_active,
|
|
"plan": Tenant.plan_id,
|
|
}
|
|
sort_column = sort_column_map.get(sort_by or "")
|
|
if sort_column is not None:
|
|
order_fn = desc if (sort_order or "").lower() == "desc" else asc
|
|
query = query.order_by(order_fn(sort_column))
|
|
|
|
total = query.count()
|
|
|
|
offset = (page - 1) * page_size
|
|
tenants = query.offset(offset).limit(page_size).all()
|
|
|
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
|
|
|
return TenantPaginatedResponse(
|
|
items=[TenantResponse.from_orm(tenant) for tenant in tenants],
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
) |