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