from sqlalchemy.orm import Session from sqlalchemy import or_, cast, String from fastapi import HTTPException, status from app.models.auth.tenant_model import Tenant from app.models.auth.tenant_module_model import TenantModule from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse import uuid from typing import Optional from app.services.auth.event_service import EventService import logging logger = logging.getLogger(__name__) class TenantService: @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" ) 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 ) db.add(tenant) db.flush() # 2. Create Tenant Modules & Build Event Targets event_targets = [] if tenant_data.modules: for mod_data in tenant_data.modules: tm = TenantModule( tenant_id=tenant.id, module_id=mod_data.module_id, assigned_environment_slug=mod_data.environment_slug, is_active=True ) db.add(tm) event_targets.append({ "module_id": str(mod_data.module_id), "environment_slug": mod_data.environment_slug }) 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 ) logger.info("Event TENANT_PROVISION_REQUESTED emitted to outbox.") 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 "modules" in update_dict: modules_data = update_dict.pop("modules") if modules_data is not None: current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all() current_map = {tm.module_id: tm for tm in current_modules} new_map = {m["module_id"]: m for m in modules_data} event_targets = [] provisioning_id = str(uuid.uuid4()) for module_id, data in new_map.items(): new_env_slug = data.get("environment_slug") if module_id in current_map: tm = current_map[module_id] if tm.assigned_environment_slug != new_env_slug or not tm.is_active: tm.assigned_environment_slug = new_env_slug tm.is_active = True event_targets.append({ "module_id": str(module_id), "environment_slug": new_env_slug }) else: tm = TenantModule( tenant_id=tenant.id, module_id=module_id, assigned_environment_slug=new_env_slug, is_active=True ) db.add(tm) event_targets.append({ "module_id": str(module_id), "environment_slug": new_env_slug }) for module_id, tm in current_map.items(): if module_id not in new_map: tm.is_active = False db.flush() if event_targets: payload = { "tenant_id": str(tenant.id), "tenant_name": tenant.tenant_name, # Note: using current name (might be old if not updated yet, but usually distinct requests) "provisioning_id": provisioning_id, "targets": event_targets } EventService.emit_event( db, event_type="TENANT_PROVISION_REQUESTED", payload=payload, tenant_id=tenant.id ) for key, value in update_dict.items(): setattr(tenant, key, value) 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, ) -> TenantPaginatedResponse: query = db.query(Tenant) 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) 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, )